您的手机刚刚连接上了无线耳塞,智能手表也将健康数据同步到了某个应用程序中,而您所在建筑物内的某个传感器也将其测得的温度信息发送给了相应的接收设备。所有这些操作都是通过低功耗蓝牙技术来完成的。而且,越来越多的设备的固件都是基于Zephyr操作系统开发的。
本手册将系统地教您如何使用Zephyr平台来开发蓝牙应用程序。您将从了解低功耗蓝牙的基础知识开始学习,包括GAP、GATT、服务以及特性这些概念的含义,然后逐步掌握如何编写实际的Zephyr固件:如何让设备进行广播以吸引其他设备的连接、如何创建自定义服务、如何处理连接请求、如何通过低功耗蓝牙读取传感器数据,以及如何构建一个能够被手机识别的完整蓝牙外围设备。
每一个概念都会配有可供实际操作的代码示例,而每段代码也都会有详细的解释,说明它的功能及其重要性。
这是一本内容详尽的指南。由于蓝牙技术涉及很多复杂的环节,因此大多数教程都会忽略那些在实际开发中容易让人犯错的部分,但本书不会这样做。请您按照书中的步骤依次学习,边学习边编写代码,到最后,您就将具备使用Zephyr平台开发实用蓝牙设备的能力。
目录
先决条件
要跟随本教程学习,您需要能够熟练阅读和编写C语言代码。指针、结构体、函数指针以及回调机制这些概念,您应该已经熟悉。您还需要一个命令行终端,并具备构建C语言项目的基本技能。此前没有蓝牙使用经验也不会影响学习进程。本文会从零开始讲解BLE相关知识。
在硬件方面,Nordic Semiconductor公司的nRF52840开发板是非常理想的选择。Nordic的芯片在Zephyr操作系统中得到了最佳的支持,而nRF52840开发板的价格也比较亲民(大约40美元),而且很容易购买到,同时还配备了内置调试工具。
如果您使用的是其他支持Zephyr操作系统且具备蓝牙功能的开发板(例如nRF52832开发板、nRF5340开发板,或者任何具有BLE功能的无线模块的开发板),也同样适合进行学习。
要测试BLE功能,您需要一部安装了BLE扫描应用程序的手机(iOS和Android系统上都可以免费使用nRF Connect手机应用)。
此外,您还需要一台运行Linux系统(Ubuntu 22.04或更高版本)、macOS系统,或者装有WSL2的Windows系统的电脑来进行测试。
什么是Zephyr操作系统?为什么选择它来开发蓝牙应用?
Zephyr操作系统是一款体积小巧、开源的实时操作系统,专为资源有限的嵌入式设备设计。它由Linux基金会负责维护,采用Apache 2.0许可证进行授权,甚至可以在内存容量仅为8KB的微控制器上运行。该操作系统支持ARM、RISC-V、x86、Xtensa等多种架构的开发板。
不过本文的重点是蓝牙技术,那么为什么说Zephyr特别适合用于BLE开发呢?
Zephyr操作系统内置了符合Bluetooth SIG标准的完整BLE功能栈。这意味着该功能栈已经通过了官方的认证流程,完全符合相关技术规范。您所使用的并不是某种业余开发的实现方案,而是一个经过严格测试、被官方认可的功能栈。
这个蓝牙功能栈涵盖了主机端的相关协议(如GAP、GATT、SMP、L2CAP、ATT)以及控制器端的相关协议(如链路层协议、HCI协议)。对于那些支持BLE功能的无线模块(主要是Nordic公司的产品系列),Zephyr还提供了自己开发的开源控制器实现代码。这意味着,从您的应用程序代码到无线模块的寄存器设置,整个蓝牙功能栈都是开源的——没有任何二进制文件,也没有任何闭源库,您可以随时阅读、调试或修改其中的任何部分。
Nordic Semiconductor公司是BLE领域最具主导力的企业,他们将自己的nRF Connect开发工具包建立在Zephyr操作系统之上。当Nordic自己的工程师编写BLE固件时,也会使用Zephyr操作系统,这一点充分说明了Zephyr的可靠性。
Zephyr蓝牙功能栈支持Bluetooth 5.x系列的所有特性,包括2M PHY传输模式、适用于远距离通信的编码PHY模式、扩展广告服务功能、Bluetooth Mesh网络技术(其中包含中继节点、代理节点等功能)、LE Audio音频标准(支持LC3编解码器、广播传输以及助听器设备),还有室内定位所需的方向检测功能,以及所有用于产品开发的标准化BLE协议和服务。
简而言之:如果你现在正在开发蓝牙低功耗产品,Zephyr绝对是最佳的选择之一。谷歌在Chromebook中使用了它,北欧国家也将它用于他们的整个软件开发工具包,而且有数百家公司都在基于这个平台来开发自己的产品。
蓝牙低功耗技术基础
在开始编写任何代码之前,你首先需要了解蓝牙低功耗技术的运作原理。如果你已经对这项技术非常熟悉,可以跳过这一部分;但如果还不了解的话,请仔细阅读,因为后续的内容都是建立在这些基础知识之上的。
蓝牙低功耗技术与“经典”蓝牙是不同的。“经典”蓝牙(即在LE Audio出现之前用于向扬声器传输音频数据的那种技术)是为连续、高数据流量的通信而设计的,而蓝牙低功耗技术则是为间歇性、低功耗的通信场景所设计的。
一个蓝牙低功耗传感器可能每分钟只会唤醒一次,发送20字节的数据,然后再次进入睡眠状态。这样的通信过程所消耗的能量仅为微瓦级。正是这种设计上的差异,使得蓝牙低功耗设备能够使用纽扣电池持续运行多年。
作为开发者,你主要需要接触蓝牙低功耗技术中的两个核心层:GAP和GATT。
GAP(通用访问配置文件)负责控制设备之间如何发现彼此并建立连接。可以把GAP看作是“在聚会上结识他人”的那个环节。
一个设备可以扮演多种角色。其中,从设备会定期发送少量数据包,用来宣告自己的存在并提供基本信息;而主设备则会监听这些数据包,并可以主动发起连接。通常情况下,手机属于主设备,而传感器、耳塞或智能手表则属于从设备。
GATT(通用属性配置文件)则决定了设备连接成功后如何进行数据交换。可以把GATT看作是“进行对话”的那个环节。
GATT定义了一种层次化的数据结构。在最高层,一个设备会暴露出一个或多个服务,而每个服务又会将相关的数据组织在一起。
例如,心率监测器可能会提供一个“心率服务”。在这个服务内部,还会包含一个或多个属性。属性其实就是包含具体数值及元数据的单个数据项。比如,“心率服务”中可能包含“心率测量属性”(用于显示实际的心率值)和“传感器位置属性”(用于说明传感器佩戴的位置)。
每个属性都具有一些属性特性,这些特性决定了你可以对该属性执行哪些操作。一个属性可能是可读的(主设备可以请求它的数值)、可写的(主设备可以设置它的数值)、可通知的(从设备可以在不需要主设备请求的情况下主动向它发送更新信息),或者只是可指示的(类似于可通知的功能,但需要主设备进行确认)。一个属性也可以同时具有多种属性特性。
所有的服务和属性都会通过UUID来唯一标识。蓝牙技术联盟为一些常见的服务和属性定义了标准的16位UUID(例如,心率服务的UUID是0x180D,电池服务的UUID是0x180F等等)。而对于那些需要自定义功能的场景,就需要你自己定义128位的UUID了。
以下是一个具体的概念模型。想象这样一个温度传感器设备:
设备名称:“我的温度传感器”
|
+-- 环境感知服务(UUID:0x181A)
| |
| +-- 温度特性(UUID:0x2A6E)
| | 功能:读取数据、发送通知
| | 值:23.5摄氏度
| |
| +-- 湿度特性(UUID:0x2A6F)
| 功能:读取数据、发送通知
| 值:65.2百分比
|
+-- 电池服务(UUID:0x180F)
|
+-- 电池电量特性(UUID:0x2A19)
功能:读取数据、发送通知
值:87百分比
上图展示了一个拥有两项服务及三项特性的设备。连接的手机可以读取温度值、接收湿度变化的通知,同时也能查看电池电量。这些服务与特性其实就是你的BLE设备的API接口。
设计这些API时,应该像设计优秀的REST API一样:思考你的设备需要暴露哪些数据,以及客户端应该如何与这些数据交互。
还有一个概念需要了解:广告数据。当外围设备进行广告广播时,它会发送一些小型数据包(在传统广告模式下数据包大小最多为31字节,而扩展广告模式下的数据包尺寸会更大)。这些数据包包含结构化信息,比如设备名称、支持的服务类型、制造商特定的数据以及各种标志位。扫描设备在尝试建立连接之前,首先会看到这些广告数据,因此它们可以被视为你的设备的“名片”。
GAP层:广告广播与连接机制
在构建BLE设备时,GAP层是你最先需要处理的层次。在任何操作发生之前,外围设备都必须先进行广告广播,以便其他设备能够发现它的存在。
广告广播的工作原理如下:外围设备的无线电模块会定期唤醒,在三个指定的广告频道之一发送一个短数据包,然后再次进入睡眠状态。处于扫描状态的中央设备会接收到这些数据包,并从而了解该设备的存在信息。
广告广播的间隔时间是一个需要权衡的因素:较短的间隔时间(例如20毫秒)可以让设备更容易被发现,但也会消耗更多的电量;而较长的间隔时间(例如1000毫秒)虽然能节省电量,但却会降低设备被发现的效率。对于大多数应用来说,100到500毫秒这个范围是比较合适的。
广告数据包中包含被称为“AD结构”的结构化字段。每个AD结构都由长度字节、类型字节以及数据字节组成。常见的数据类型包括标志位(用于指示设备是否可被发现,以及是否支持BR/EDR协议)、设备的完整名称或简化名称、服务UUID列表、发射功率等级,以及制造商特定的数据。
对于蓝牙4.x版本,广告数据包的总大小被限制在31字节以内,因此你无法在其中包含太多信息。通常情况下,你必须选择是包含设备名称还是服务UUID,因为两者可能同时无法被容纳进去。
蓝牙5.0引入了扩展广告功能,这使得每个数据包的大小可以达到254字节,而且可以通过多个频道进行广告广播,不再仅限于原来的三个专用频道。此外,还可以同时执行多组广告广播操作。如果硬件和控制器支持扩展广告功能,Zephyr操作系统也会提供相应的支持。
当中央设备决定建立连接时,它会向外围设备发送连接请求。这两台设备会协商一些连接参数:连接间隔(它们通信的频率,通常在7.5毫秒到4秒之间)、外围设备的延迟时间(为了节省电量,外围设备可以忽略多少次连接请求)以及监控超时时间(在多长时间后才会认为连接已经丢失)。
这些参数会同时影响数据传输速度和功耗。较短的连接间隔能够实现快速的数据传输,但会消耗更多的电量;而较高的外围设备延迟时间虽然有助于节省电量,却会增加数据交换的延迟。
GATT层:服务与特性
一旦连接建立成功,GATT层就会开始发挥作用。连接后的设备会通过之前介绍的服务/特性层次结构来交换数据。
在外围设备端,你需要定义GATT数据库,即指定自己的设备提供哪些服务和特性;而在中央设备端,则需要执行服务发现操作,也就是向外围设备查询其可用的服务和特性。
GATT数据库中的每个特性都包含多个组成部分。值代表实际的数据内容(通常是一个字节数组);属性则定义了允许执行的操作类型,其中最常见的操作包括读取(0x02)、无响应写入(0x04)、写入(0x08)、通知发送(0x10)和指示操作(0x20);权限设置则涉及到安全方面的要求,比如读写操作是否需要加密、认证或授权。
对于那些需要发送通知或进行指示操作的特性来说,它们还会包含一个客户端特性配置描述符(CCCD)。这是一个由2个字节组成的特殊值,中央设备通过写入这个值来决定是否启用相应的通知/指示功能。如果中央设备写入0x0001,通知功能就会被启用;如果写入0x0000,则通知功能会被禁用。当你在定义某个特性时设置了“通知发送”属性,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的构建过程提供了所需的编译工具链、构建系统及实用工具。
对于Zephyr而言,设备树编译器(device-tree-compiler)尤为重要,因为它能够解析那些描述硬件架构的文件,从而让构建系统了解您的开发板的外设配置及引脚分配情况。
安装Zephyr的命令行工具“west”:
pip3 install west
“west”用于管理Zephyr所使用的多仓库工作区,并提供了用于构建、刷新固件以及进行调试的各种命令。这是您最常使用的工具。
初始化并更新工作区:
west init ~/zephyrproject
cd ~/zephyrproject
west update
执行west init命令会创建一个工作区,并克隆Zephyr的主代码仓库;而west update命令则会下载所有模块依赖项,包括供应商提供的HAL库、加密库、蓝牙控制程序代码等其他组件。由于这些数据的体积较大,因此下载过程需要花费一定时间。
安装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.sh
该SDK包含了针对ARM、RISC-V、x86、Xtensa等多种架构的GCC编译工具链。setup.sh脚本会帮助将这些工具链注册到CMake系统中。请查看Zephyr的官方发布页面,以获取最新的SDK版本信息,因为在本手册编写之后,该版本号可能会发生变化。
设置环境变量(将以下内容添加到您的~/.bashrc或~/.zshrc文件中):
export ZEPHYR_BASE=~/zephyrproject/zephyr
source ~/zephyrproject/zephyr/zephyr-env.sh
ZEPHYR_BASE变量用于告知构建系统Zephyr源代码目录的位置,而zephyr-env.sh脚本则会配置其他相关路径。完成这两项设置后,您就可以从任意目录中为任何支持的开发板进行构建了。
你的第一个BLE应用程序:一个简单的信标设备
我们将从最简单的BLE应用程序开始入手:这种设备仅用于发送广告信息,不执行其他任何操作。没有连接功能,没有服务功能,也不进行数据交换。它仅仅是一个用来宣告自身存在的信标设备而已。
创建项目结构:
mkdir -p ~/my_ble_apps/beacon/src
这样就可以生成标准的Zephyr应用程序目录结构。每个Zephyr应用程序都会存放在自己的目录中,其中会包含一个用于存放C源代码的src/子目录。项目根目录则存放构建配置文件。
创建文件~/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的构建目标中,而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
#include
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_NAMECOMPLETE,
'M', 'y', 'B', 'e', 'a', 'c', 'o', 'n'),
};
int main(void)
{
int err;
printk("正在启动BLE信标功能\n");
err = bt_enable(NULL);
if (err) {
printk("蓝牙初始化失败(错误代码:%d)\n", err);
return 0;
}
printk("蓝牙已成功初始化\n");
err = bt_le_adv_start(BT_LEADV_NCONN, ad, ARRAY_SIZE(ad), NULL, 0);
if (err) {
printk("广告发送功能启动失败(错误代码:%d)\n", err);
return 0;
}
printk("信标设备正在发送广告信息\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 flash
west build命令会为nRF52840 DK编译应用程序,生成一个ELF二进制文件和一个HEX文件,这些文件会被保存在build/目录中。west flash命令则会通过板载的J-Link调试器将这个二进制文件刷入开发板上,系统会自动检测到连接的开发板,并使用正确的编程协议来完成刷入操作。
在手机上打开nRF Connect应用程序,开始扫描操作,你应该会看到“MyBeacon”出现在已发现设备的列表中。这就是运行在你的开发板上的固件,它正在通过BLE协议发送广告信号。
构建具有自定义服务的BLE外设
对于某些应用来说,能够广播信号的信标设备确实非常有用(例如iBeacon、Eddystone或资产追踪系统)。但大多数BLE设备都需要具备可连接性,并且需要通过GATT服务来暴露相关数据。现在,你将构建一个具有自定义服务的BLE外设。
你会创建一个“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=y
CONFIG_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
#include
#include
#include
#include
/* 自定义服务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 BTUuid_LED_CHAR_VAL \
BT_uuid_128_encode(0x00001235, 0x0000, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_LED_CHAR BT_UUID_DECLARE_128(BTUuid(LED_CHAR_VAL)
/* 按钮特性UUID */
#define BT_UUID_BUTTON_CHAR_VAL \
BT_uuid_128_encode(0x00001236, 0x0000, 0x1000, 0x8000, 0x00805f9b34fb)
#define BT_UUID_BUTTON_CHAR BTUuid_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_NAMECOMPLETE, 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设备未准备好\n");
return 0;
}
gpio_pin_configure_dt(&led, GPIO_OUTPUT_INACTIVE);
gpio_pinconfigure(dt(&button, GPIO_INPUT);
err = bt_enable(NULL);
if (err) {
printk("蓝牙初始化失败(错误代码:%d)\n", err);
return 0;
}
printk("蓝牙已成功初始化\n");
err = bt_le_adv_start(BT_LEADV_CONN, ad, ARRAY_SIZE(ad),
sd, ARRAY_SIZE(sd));
if (err) {
printk("广告功能启动失败(错误代码:%d)\n", err);
return 0;
}
printk("当前正在以‘%s’作为设备名称进行广告宣传\n", CONFIG_BT_DEVICE_NAME);
return 0;
}
这是一段相当长的代码,因此我们还是逐部分来分析它吧。
代码开头的UUID定义用于为该服务及其两个特性生成自定义的128位UUID。在开发自定义的BLE设备时,你需要自行生成这些UUID,而不是使用蓝牙标准组织提供的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`这个辅助函数来正确处理偏移量和数据长度的问题(如果需要读取的数据量超过了MTU的最大值,读取操作可能会只完成部分数据)。函数最终会返回当前LED的状态,这个状态是以一个字节的形式表示的。
当中央设备向LED特性字段写入数据时,会调用`write_led`回调函数。该函数会首先确认确实只写入了一个字节的数据,然后更新`led_state`变量,并通过`gpio_pin_set_dt`函数来实际改变LED的状态。函数还会返回实际被写入的字节数量(即`len`值),如果写入的长度有误,就会返回相应的GATT错误代码。BT_GATT_ERR宏会将这些错误代码包装成堆栈期望接受的返回值形式。
`read_button`回调函数会读取在请求读取数据时LED引脚的实际状态。该函数会通过`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_LEADV_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数据库编译成一个二进制文件,然后通过烧录命令将该文件下载到开发板上。由于CONFIG_BT_PERIPHERAL选项已被启用,因此生成的二进制文件的大小会比之前仅包含信标功能的版本大很多——因为其中包含了可连接功能、GATT服务器功能以及ATT协议层。
在手机上打开nRF Connect应用,进行扫描,找到“Zephyr LED”后点击“连接”。连接成功后,你会看到列出的GATT服务。在其中找到自定义服务(其UUID以00001234开头),你还会看到两个相关特性:通过读取“按钮”特性可以了解按钮的状态;向“LED”特性写入0x01即可点亮LED灯,写入0x00则能将其熄灭。这样,你就成功地通过蓝牙从手机控制了硬件设备。
处理连接状态及连接回调函数
在实际应用中,你需要知道设备何时连接或断开连接。例如,你可能希望在设备连接时停止发送广告信号以节省电量;而在设备断开连接后重新开始发送广告信号以便用户能够再次连接;或者通过LED灯的状态来指示当前的连接情况。
Zephyr提供了通过注册机制来实现这些功能的连接回调函数:
#include <zephyr/bluetooth/conn.h>
static void connected(struct bt_conn *conn, uint8_t err)
{
if (err) {
printk("连接失败(错误代码:%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("已连接设备地址:%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("已断开连接,原因代码:%u\n", addr, reason);
/* 在连接断开后重新开始发送广告信号 */
bt_le_adv_start(BT_LEADV_CONN, ad, ARRAY_SIZE(ad),
sd, ARRAY_SIZE(sd));
}
BT_conn_CB_DEFINE(conn_callbacks) = {
.connected = connected,
.disconnected = disconnected,
};
BTCONN_CBDefine宏用于静态注册一组连接回调函数。当连接建立时,.connected回调函数会被调用;其中传递的err参数用于指示连接是否成功(0表示连接成功)。当连接断开时,.disconnected回调函数会被调用,此时传递的reason参数会说明连接中断的具体原因(例如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("配置信息已更新: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属性确保了各个字段之间没有填充字节,从而使数据格式与手机发送的数据完全一致。如果没有这个属性,编译器可能会在mode和interval_ms字段之间插入填充字节以保持对齐,从而导致数据不匹配。
写入回调函数会进行两项验证:首先检查偏移量是否为0(在这种简单的情况下不需要部分写入操作);其次检查传入的数据长度是否与结构体的实际大小相符。如果其中任何一项验证失败,就会返回一个GATT错误代码,中央设备会将这个错误代码作为写入响应错误来处理。
在BLE应用中,验证输入数据至关重要,因为中心节点是通过无线方式发送原始字节的。任何格式错误的数据都应被拒绝,而不能被盲目接受。
memcpy函数会将经过验证的数据复制到配置结构体中。在实际应用中,你很可能会根据这些新配置来调整设备的行为(例如改变传感器的轮询间隔、切换工作模式等等)。
在写入回调函数中,flags参数用于指示这是“需要响应的写入操作”还是“无需响应的写入操作”。你可以通过检查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)
{
notificationsenabled = (value == BT_GATT_CCC_NOTIFY);
printk("通知功能 %s\n", notificationsEnabled ? "已启用" : "未启用");
}
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_DEFINESENSOR_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宏用于添加“客户端特性配置描述符”。当中心节点通过修改CCCDD来启用或禁用通知功能时,就会调用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_serviceattrs[1],
&sensor_value, sizeof(sensor_value));
if (err) {
printk("发送通知失败(错误代码:%d)\n", err);
}
}
bt_gatt_notify函数会向所有已连接且已启用该特性通知功能的设备发送通知。
第一个参数为NULL,表示向所有连接设备发送通知;也可以传入特定的bt_conn指针,以仅通知某个设备。第二个参数是指向GATT表中相应特性属性的指针。&sensor_serviceattrs[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
#include
#include
#include
#include
#include
/* UUID定义 */
#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 BTUUID_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_notificationsenabled;
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_notificationsenabled = (value == BT_GATT_CCC_NOTIFY);
printk("温度通知功能是否启用: %s\n",
tempnotifications_enabled ? "已启用" : "未启用");
if (tempNotificationsenabled) {
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("通知间隔已更改为 %u 毫秒\n", notify_interval_ms);
if (temp_notificationsenabled) {
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_notificationsenabled) {
int err = bt_gatt_notify(NULL, &env_serviceattrs[2],
&temperature_value,
sizeof(temperature_value));
if (err && err != -ENOTCONN) {
printk("通知功能调用出错: %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_NAMECOMPLETE, CONFIG_BT_DEVICE_NAME,
sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};
static const struct bt_data sd[] = {
BT_DATA_BYTES(BT数据和UUID128_ALL, BT_UUID_ENV_SERVICE_VAL),
};
/* 连接状态相关回调函数 */
static void connected(struct bt_conn *conn, uint8_t err)
{
if (err) {
printk("连接失败,错误代码为 %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("已连接设备地址为: %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("断开连接,原因代码为 %u\n", addr, reason);
if (current_conn) {
bt_conn_unref(current_conn);
current_conn = NULL;
}
temp_notificationsenabled = false;
k_work_cancel_delayable(&sensor_work);
gpio_pin_set_dt(&status_led, 0);
bt_le_adv_start(BT_LEADV_CONN, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
}
BTCONN_CB_DEFINE(connCallbacks) = {
.connected = connected,
.disconnected = disconnected,
};
int main(void)
{
int err;
if (!gpio_is_ready_dt(&status_led)) {
printk("LED未准备好,无法使用\n");
return 0;
}
gpio_pin_configure_dt(&status_led, GPIO_OUTPUT_INACTIVE);
err = bt_enable(NULL);
if (err) {
printk("蓝牙初始化失败,错误代码为 %d\n", err);
return 0;
}
printk("蓝牙已成功初始化\n");
err = bt_le_adv_start(BT_LEADV_CONN, ad, ARRAY_SIZE(ad),
sd, ARRAY_SIZE(sd));
if (err) {
printk("广告广播失败,错误代码为 %d\n", err);
return 0;
}
printk("环境传感器已准备好,正在以 ‘%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_WORK_QUEUESTACK_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支持多种配对方式。“简单配对”模式只提供加密功能,不进行身份验证,因此可以防止被动窃听行为,但无法抵御主动的中间人攻击。“密码输入”模式要求用户输入6位数字密码,从而完成身份验证:“数值比对”模式会在两台设备上显示相同的数值,用户需要确认这些数值是否一致;“带外配对”模式则通过外部通道(如NFC)来交换配对信息。
要在你的prj.conf文件中启用安全功能,请设置以下参数:
CONFIG_BT_SMP=y
CONFIG_BT_SETTINGS=y
CONFIG_flash=y
CONFIGFLASH_MAP=y
CONFIG_NVS=y
CONFIG-settings=y
CONFIG_BT_SMP=y 这一设置会启用安全管理协议,该协议负责设备配对过程。通过“设置”“闪存”和“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 来要求进行身份验证后的配对操作(支持密码输入或数字比较方式,而不仅仅是简单的“立即配对”功能)。
需要注册身份验证回调函数来处理密码的显示或输入逻辑:
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("设备 %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("配对操作已取消:%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(&authCallbacks);
passkey_display 这个回调函数会在系统生成密码时被调用,用户需要在中央设备上输入这个密码才能完成配对。对于带有显示屏的设备,可以在屏幕上显示密码;而对于没有显示屏的设备(比如传感器),则可以在开发阶段通过串行控制台输出密码,在生产环境中则可以直接使用“立即配对”功能。
bt_conn_auth_cb_register 这个函数用于将这些回调函数注册到系统中。任何时候,系统中只能有一组回调函数处于活跃状态。
实现标准的 BLE 心率测量配置文件
到目前为止,示例代码中使用的都是自定义的 128 位 UUID。但在实际生产环境中,许多 BLE 设备都会使用蓝牙技术联盟定义的标准配置文件。标准配置文件使用 16 位 UUID,这样不仅可以节省广告传输所需的空间,还能让像 nRF Connect 这样的通用应用程序能够自动解析数据,并以人类可读的形式显示结果。心率测量配置文件就是其中最常用的标准配置文件之一,它很好地展示了如何在 Zephyr 系统中实现这些标准配置文件。
心率服务对应的 UUID 是 0x180D,其中包含一个名为“心率测量”的特性,其 UUID 为 0x2A37。这个特性会通过通知机制来发送心率数据。根据蓝牙技术联盟的规定,这种数据的字节格式是有明确规定的:第一个字节用于表示各种标志信息,其余的字节则用来存储心率数值,以及能量消耗值、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_notificationsenabled;
static void hr_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
hrNotificationsenabled = (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_GATTPRIMARY_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_notificationsenabled) {
return;
}
uint8_t hr_data[2];
hr_data[0] = 0x00; /* 标志位:采用uint8格式,没有其他附加字段 */
hr_data[1] = heart_rate_bpm;
bt_gatt_notify(NULL, &hr_serviceattrs[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中为所有标准的蓝牙服务及特性定义了相应的宏。使用这些标准UUID意味着,任何手机上的BLE心率监测应用都可以自动发现你的设备、与之连接,并显示该设备提供的数据,而无需进行任何自定义的应用开发。
“心率测量”这一特性所允许的操作仅限于接收通知数据,因此其权限设置为BT_GATT_PERM_NONE。既不允许读取数据,也不允许写入数据:所有数据都只能通过通知机制来传递。
通知数据中的第一个字节(hr_data[0])用于存储标志位信息,这些标志位是由蓝牙规范定义的。当值为0x00时,表示心率数据采用uint8格式进行存储(数值范围为0到255次/分钟),且没有其他可选字段;如果将第0位设置为1,则表示心率超过255次/分钟时,数据会以uint16格式存储;设置其他位则可以表示数据中包含了消耗的能量信息或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_NAMECOMPLETE, 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会启用扫描功能,该功能提供了带有过滤功能的高级扫描接口。
BLE中心节点首先会开始扫描周围的外围设备:
#include
#include
#include
#include
#include
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_CREATECONN,
BT_LE_CONNPARAM_DEFAULT,
&default_conn);
if (err) {
printk("连接失败 (错误代码 %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("蓝牙初始化失败 (错误代码 %d)\n", err);
return 0;
}
printk("开始BLE扫描...\n");
err = bt_le_scan_start(BT_LE_SCAN_ACTIVE, device_found);
if (err) {
printk("扫描失败 (错误代码 %d)\n", err);
return 0;
}
return 0;
}
bt_le_scan_start函数会开始扫描BLE广告包。该函数的第一个参数BT_LE_SCAN_ACTIVE用于启用主动扫描模式,这意味着扫描设备会向发送广告包的设备发送请求包以获取相应的响应数据;而被动扫描模式下,设备仅会接收广告包而不主动发送请求包。第二个参数是一个回调函数,每当收到广告包时,这个回调函数就会被调用。
device_found回调函数会接收到目标设备的地址、RSSI值(以分贝毫瓦为单位表示的信号强度)、广告类型以及原始的广告数据。
在这个示例中,代码会根据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_GATTIteration_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则只会发现主服务。此外,也可以将type设置为BT_GATT_DISCOVER_characterISTIC来发现某个服务中的具体特征,或者设置为BT_GATT_DISCOVER_descriptor来发现特征中的描述符信息。
每当发现一个新的属性时,就会调用一次发现回调函数(discover_func);当所有属性的发现工作完成之后,还会再次调用该函数,此时attr会被设置为NULL。如果返回BT_GATT_ITER_CONTINUE,则表示堆栈应继续进行属性发现操作;而返回BT_GATTIteration_STOP则会使发现过程提前终止。
在发现相应的服务及特性后,可以使用bt_gatt_read读取特性值,也可以使用bt_gatt_subscribe订阅相关通知。调用bt_gatt.subscribe函数时,需要传入一个bt_gatt_subscription_params结构体,该结构体用于指定要订阅的特性、通知回调函数以及用于写入的CCC值(即BT_GATT_CCC_NOTIFY字段)。
MTU协商与数据传输效率
BLE ATT协议的默认最大传输单元为23字节。如果扣除3字节的协议开销,那么每次GATT操作实际可传输的数据量仅为20字节。对于读取温度值这样的简单操作来说,20字节的传输量已经足够了;但对于传输固件镜像或大量传感器数据而言,每次仅20字节的传输速度显然太慢了。
通过MTU协商,两个相连的设备可以共同确定一个更大的最大传输单元,其大小最高可达517字节(即BLE协议允许的最大值)。较大的最大传输单元意味着每个数据包中能包含更多的数据,从而减少数据传输所需的往返次数,进而提高整体传输效率。以带有2M PHY层的nRF52840芯片为例,当最大传输单元从23字节增加到247字节时,数据传输效率可以提高5到10倍。
你可以在prj.conf文件中配置更大的最大传输单元,具体配置如下:
CONFIG_BT_L2CAP_TX_MTU=247
CONFIG_BT_BUF_ACL_RX_SIZE=251
CONFIG_BTBUFACL_TX_SIZE=251
CONFIG_BT_CONTROLR_DATA_LENGTH_MAX=251
CONFIG_BT_L2CAP_TX_MTU=247这一设置决定了设备在协商过程中会请求使用的最大传输单元大小。通常选择247这个数值,是因为它与链路层允许的最大单包数据长度(251字节减去4字节的L2CAP头部信息)相匹配。
CONFIG_BTBUF_ACL_RX_SIZE和CONFIG_BT BUFACL_TX_SIZE用于设置ACL缓冲区的大小,以便能够容纳更大的数据包。CONFIG_BT_CONTROLR_DATA_LENGTH_MAX这一配置使得控制器层能够支持数据长度扩展功能,从而使链路层能够发送更长的数据包,而无需将它们分割成27字节的片段进行传输。
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协商失败(错误代码:%u)\n", att_err);
return;
}
uint16_t mtu = bt_gatt_get_mtu(conn);
printk("协商后的最大传输单元为:%u字节\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大小(MTU越大,所需发送的数据包数量就越少)、连接间隔时间(间隔越短,传输机会就越多),以及所使用的PHY层技术(2M PHY层的原始数据传输速率是1M PHY层的两倍)。
如果使用247字节的MTU值、7.5毫秒的连接间隔时间,并且采用2M PHY层技术,那么根据具体的控制器型号和无线通信环境,实际传输速率可以在800千比特每秒到1400千比特每秒之间变化。
需要注意的一点是:iOS系统和Android系统在处理MTU协商机制时存在差异。Android系统允许用户通过应用程序层面来指定特定的MTU值,而iOS系统则会自动协商出设备支持的最大MTU值(通常为185字节或251字节,具体取决于iOS版本)。因此,你的固件应该能够适应任何经过协商确定的MTU值,而不是预先假设某个固定的数值。
用于提升传输范围和速度的PHY层选择
蓝牙5.0在原有的1M PHY层之外,新增了两种物理层选项。选择不同的PHY层会影响到传输范围、传输速率以及功耗。
1M PHY层是默认设置,也是蓝牙4.x版本中唯一可用的PHY层。它的数据传输速率为每秒1兆比特,传输范围也符合标准要求。所有BLE设备都支持1M PHY层。
2M PHY层的数据传输速率提高了一倍,达到每秒2兆比特。由于每个数据包的传输时间缩短了一半,因此设备的无线通信模块处于活跃状态的时间也会减少。这样一来,既提高了传输速率(单位时间内能传输更多数据),也降低了功耗。不过,与1M PHY层相比,2M PHY层的传输范围会略有下降,因为接收端需要更长的时间来解析每个传入的比特位。因此,当中央设备与外围设备之间的距离较近(在几米范围内)且传输速率非常重要时,就应该选择2M PHY层。
编码PHY层通过使用前向纠错技术,能够显著延长传输范围,其传输距离通常是1M PHY层的2到4倍。这种技术是通过为每个数据位添加冗余编码来实现的(当编码级别为S=2时,传输范围可扩大2倍;当编码级别为S=8时,传输范围可扩大4倍)。不过,这种技术的代价是会降低传输速率:例如,当编码级别为S=8时,实际的数据传输速率仅为125千比特每秒,这比1M PHY层的速度慢了8倍。因此,编码PHY层适用于那些需要长距离传输数据的应用场景(比如户外资产追踪、覆盖整栋建筑的传感器网络等),并且这些应用能够接受较低的传输速率。
要在prj.conf文件中启用相应的PHY层支持功能,可以添加以下配置:
CONFIG_BT_USER PHY_UPDATE=y
CONFIG_BT_CONTROLR_PHY_2M=y
CONFIG_BT_CONTROLR PHY_CODED=y
CONFIG_BT_USER PHY_UPDATE=y这一配置允许应用程序在连接建立后请求更新PHY层设置。CONFIG_BT_CONTROLRPHY_2M和CONFIG_BT_CONTROLR_PHY_CODED则分别用于启用控制器中对2M PHY层和编码PHY层的支持功能。
连接后请求进行PHY更新:
static void connected(struct bt_conn *conn, uint8_t err)
{
if (err) {
return;
}
/* 为获得更高的传输速率,请求使用2M PHY */
struct bt_conn_lePHY_param phy_param = {
.options = BT_CONN_LE PHY_OPT NONE,
.pref_tx_phy = BT_GAP.LEPHY_2M,
.pref_rx_PHY = BT_gap.LEPHY_2M,
};
int phy_err = bt_conn_lePHY_update(conn, &phy_param);
if (phy_err) {
printk("PHY更新请求失败(错误代码为%d)\n", phy_err);
}
}
bt_conn_le PHY_update函数会向远程设备发送PHY更新请求。pref_tx_phy和pref_rxPHY字段分别表示用于数据传输和接收的优选PHY类型。远程设备可以接受这个请求,也可以建议使用其他类型的PHY。只有当双方设备都支持所请求的PHY类型时,更新才能成功完成。
若需监控PHY类型的变化,可以注册一个回调函数:
static void phy_updated(struct bt_conn *conn,
struct bt_conn_le PHY_info *param)
{
printk("PHY类型已更新:发送侧使用的PHY为%u,接收侧使用的PHY为%u\n",
param->tx_phy, param->rxPHY);
}
BT_CONN_CB_DEFINE(conn_callbacks) = {
.connected = connected,
.disconnected = disconnected,
.le PHY_updated = phy_updated,
};
每当连接的PHY类型发生变化时,lePHY_updated回调函数就会被触发。tx_phy和rxPHY字段会显示当前用于数据传输和接收的PHY类型。其中,1表示1M PHY,2表示2M PHY,4表示编码PHY。通常情况下,发送侧和接收侧使用的PHY类型是相同的,但某些情况下也可能不同。
对于采用S=8编码方式的编码PHY,需要设置相应的选项:
struct bt_conn_le_phy_param phy_param = {
.options = BT_CONN_LE PHY_OPT_CODED_S8,
.pref_txPHY = BT_gap.LEPHY_CODED,
.pref_rxPHY = BT_GAP.LEPHY_CODED,
};
BTCONN_LE PHYOPT_CODED_S8选项表示选择S=8编码方式。这种编码方式虽然会导致传输速率降低,但能够实现最大的信号覆盖范围。如果省略这个选项(或选择BT_CONN.LEPHY_OPT_CODED_S2),则系统会采用S=2编码方式,这种方式在保证较好传输速率的同时,也能提供一定的信号覆盖范围。
通过BLE进行固件更新
对于任何一款实际投入使用的BLE产品来说,能够向现场设备部署固件更新功能都是至关重要的。用户无需连接USB线或前往服务中心,就能获得漏洞修复和新功能。
Zephyr通过集成安全的开源引导加载程序MCUboot,支持通过BLE进行设备固件更新。
DFU架构由两个部分组成。MCUboot是在应用程序启动之前运行的引导加载程序。它管理着两个固件区域:当前正在使用的固件区域以及待更新的固件区域。当新的固件被写入待更新区域后,MCUboot会验证其加密签名,然后交换这两个区域的位置,并启动新固件。如果新固件无法通过验证,则系统会在下一次重启时自动恢复到之前的版本。
用于设备固件更新功能的BLE传输机制是通过GATT服务来使用SMP(简单管理协议)实现的。mcumgr库实现了SMP协议,而Zephyr系统也包含了能够提供SMP GATT服务的BLE传输模块。手机应用程序(如nRF Connect或mcumgr CLI)可以连接到这个服务,并分块上传新的固件文件。
在您的prj.conf文件中启用设备固件更新功能:
CONFIG_BOOTLOADER_MCUBOOT=y
CONFIG_MCUMGR=y
CONFIG_MCUMGR_TRANSPORT_BT=y
CONFIG_MCUMGR_GROUP_IMG=y
CONFIG_MCUMGR_GROUP_OS=y
CONFIGImg_MANAGER=y
CONFIG_STREAM_flash=y
CONFIGFLASH_MAP=y
CONFIG_FLASH=y
CONFIG_BOOTLOADER_MCUBOOT=y这一设置告诉构建系统,该应用程序应在MCUboot环境下运行,这样会改变链接脚本及固件文件的格式。CONFIG_MCUMGR=y表示启用mcumgr管理库。CONFIG_MCUMGR_TRANSPORT_BT=y则开启了BLE SMP传输功能,从而创建出可供手机连接的GATT服务,用于上传固件文件。CONFIG_MCUMGR_GROUP_IMG=y允许执行与固件文件相关的操作(如上传、确认、删除);CONFIG_MCUMGR_GROUP_OS=y则启用操作系统管理相关功能(如重置设备、查看系统信息)。
在您的应用程序中注册BLE SMP传输模块:
#include
int main(void)
{
int err;
err = bt_enable(NULL);
if (err) {
printk("蓝牙初始化失败,错误代码为 %d\n", err);
return 0;
}
/* 启用用于设备固件更新功能的BLE SMP传输 */
smp_BT_register();
/* 开始发布广告信息(包含SMP服务的UUID) */
bt_le_adv_start(BT_LEADV_CONN, ad, ARRAY_SIZE(ad), NULL, 0);
printk("支持设备固件更新的设备已准备就绪\n");
return 0;
}
调用smp_BT_register()后,SMP GATT服务就会被注册到蓝牙系统中。此后,任何连接到该系统的BLE中央设备都能发现这个服务,并通过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会占用闪存的前半部分空间,而您的应用程序则会从主存储区域启动。
要执行设备固件更新操作,您需要先编译出新的应用程序版本,这样会在构建目录中生成一个zephyr.signed.bin文件。然后可以使用nRF Connect移动应用或mcumgr命令行工具将这个文件上传到设备上。上传过程是通过BLE SMP连接来完成的。上传完成后,设备会重新启动,MCUboot会验证新固件文件的合法性,并将其替换为当前正在使用的固件版本。
MCUboot具备多项对生产应用而言至关重要的安全功能。镜像签名机制能够确保只有使用您的私钥签名的固件才能被安装;镜像加密功能可以防止在传输过程中对固件镜像进行逆向工程分析;而回滚保护机制则能在新版本无法成功启动时使系统恢复到之前的固件版本。虽然这些功能需要额外的配置步骤,但对于任何支持空中更新功能的产品来说,它们都是必不可少的。
Zephyr平台上的蓝牙Mesh技术
对于那些需要直接与手机或网关进行通信的设备来说,BLE点对点连接技术使用起来非常方便。但当你需要控制建筑物内的数百盏灯泡时,这种连接方式就无法满足需求了——因为你无法逐一连接每一盏灯。而蓝牙Mesh技术恰恰解决了这个问题。
蓝牙Mesh是一种多对多的网络通信标准,它建立在BLE技术的基础之上。在蓝牙Mesh网络中,各个设备会互相传递信息,从而大大扩展信号传输的范围,使其远远超过单一BLE连接的极限。例如,一个用于打开灯光的指令可以由某一台设备发出,然后通过多个中继节点进行传递,最终到达建筑物内的每一盏灯泡。
Zephyr操作系统本身就包含了完整的蓝牙Mesh实现功能。以下是其概念性架构示意图。
在蓝牙Mesh网络中,各个设备都会承担特定的角色。中继节点负责将其他设备的消息转发下去,从而扩展网络的覆盖范围;代理节点则充当连接GATT协议设备(如手机)与蓝牙Mesh网络之间的桥梁,这样没有蓝牙Mesh支持的手机也可以通过代理节点来控制这些设备;好友节点会为那些大部分时间处于休眠状态的低功耗设备保存信息;而低功耗节点则会定期唤醒,并向“好友节点”请求已保存的消息。
蓝牙Mesh网络采用发布/订阅机制进行通信:设备会将消息发送到特定的地址,而订阅了这些地址的设备就会接收到这些消息。例如,一个灯开关会向某个群组地址发送“打开”的指令,所有订阅了这个群组地址的灯泡都会随之被点亮。
蓝牙Mesh网络中的数据是通过模型来组织的。每个模型定义了一组设备可以发送或接收的消息类型。蓝牙技术联盟为常见的应用场景制定了标准模型,例如通用开关控制模型、调光模型、传感器数据传输模型以及灯光参数设置模型等。当然,用户也可以自定义这些模型。
以下是在`prj.conf`文件中配置的基本蓝牙Mesh节点设置示例:
CONFIG_BT=y
CONFIG_BT.Mesh=y
CONFIG_BT_meshRELAY=y
CONFIG_BT_MESH_PB_adv=y
CONFIG_BT Mesh(pb_GATT=y
CONFIG_BT.Mesh_GATT_PROXY=y
CONFIG_BT.Mesh_CFGCLI=y
CONFIG_BTMesh_HEALTH_SRV=y
CONFIG_BT.Mesh=y这一配置项会启用蓝牙Mesh功能;CONFIG_BT_meshRELAY=y则使该节点具备为其他节点转发消息的能力;CONFIG_BT_MESH_PB_adv=y和CONFIG_BT Mesh(pb_GATT=y允许通过广告连接和GATT连接两种方式来添加设备到蓝牙Mesh网络中;CONFIG_BT.Mesh_GATT_PROXY=y则使该节点能够充当手机与蓝牙Mesh网络之间的代理。
要构建一个完整的蓝牙Mesh应用,你需要定义设备的功能配置、实现相应的模型处理逻辑,并完成设备的加入流程。Zephyr操作系统的示例目录`samples/bluetooth/mesh/`中包含了多个完整的演示案例,其中包括灯泡、灯开关以及传感器服务器等示例代码,这些案例能够帮助你全面了解如何使用蓝牙Mesh技术。
LE Audio:蓝牙音频技术的下一代
LE Audio是多年来蓝牙规范中最重要的新增功能。它用完全基于BLE构建的新系统取代了传统的经典蓝牙音频协议(A2DP)。Zephyr对LE Audio的实现是所有开源技术栈中最完备的之一。
LE Audio的核心是LC3低复杂度通信编解码器。与经典蓝牙音频中使用的SBC编解码器相比,LC3在保持相同比特率的情况下能够提供更优质的音效,这意味着它既能提升音质,又能降低功耗。
LE Audio引入了两种通信模式。连接式等时流是一种点对点的音频传输方式,其效率高于传统蓝牙音频协议,常用于手机与耳塞之间的连接、助听器等场景。
广播式等时流则是一种一对多的音频广播机制,一个音源可以同时向多个接收设备发送音频信号。这项技术正是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_LEVELDBG=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 这样的命令,通过串行控制台来交互式地控制和检查蓝牙堆栈的状态。这对于测试来说非常有用,因为你可以手动触发各种操作并观察结果,而无需修改固件。
nRF Connect for Mobile(适用于iOS/Android系统)是一个非常重要的辅助工具。除了具备扫描和连接功能外,它还能显示所有的GATT服务和特性,允许你读取/写入/订阅这些特性,展示原始的广告数据,并记录带有时间戳的BLE事件。利用这个工具,你可以验证你的设备是否正确地发布了广告信息,确认你的GATT数据库是否正常,以及读写操作和通知功能是否按预期工作。
蓝牙嗅探器 可以捕获实际的无线电数据包。nRF52840开发板配合Nordic提供的nRF Sniffer for Bluetooth LE固件,就可以被用作嗅探工具。如果再结合使用Wireshark(其中包含了BLE协议解析功能),你就可以仔细分析线路上传输的每一个数据包:包括广告数据包、连接事件信息、GATT请求/响应以及配对过程中的通信内容。当协议层出现故障时,这些工具就是极其重要的调试手段。
常见的调试技巧:如果无法检测到广告信号,请检查广告数据是否没有超过31字节(对于旧版本的蓝牙协议而言),同时确认标志字段是否存在。
如果连接会立即中断,请检查是否已经启用了 CONFIG_BT_PERIPHERAL (而不仅仅是 CONFIG_BT_BROADCASTER)。如果通过GATT读取数据时返回的是空字符串,那么请验证你的读取回调函数是否从 bt_gatt_attr_read 中获取到了正确的值。如果通知功能没有正常工作,请确认中心设备已经将0x0001写入CCCDD字段中。如果配对失败,请确保 CONFIG_BT_SMP=y 这一选项已被设置,并且相关的认证回调函数也已经注册正确。
BLE设备的功耗优化
如果一款BLE设备在一周内就会耗尽电池电量,那么这款产品显然是不合格的。功耗优化并不是一项事后才考虑的因素,而应该是设计阶段就必须重点关注的核心要求。Zephyr提供了相应的工具,但你需要正确使用它们才能发挥这些工具的作用。
在BLE设备中,耗电最大的部件无疑是无线电模块。每次广告发送、连接建立或扫描操作都会使无线电发射器或接收器短暂启动,每次持续几毫秒,从而消耗数毫安的电流。因此,减少无线电模块的使用是延长电池寿命的关键所在。
对于广告发送功能,可以适当增加发送间隔时间。例如,1000毫秒的间隔所消耗的电量大约只有200毫秒间隔的五分之一;如果快速发现功能并非必不可少,那么还可以进一步延长间隔时间。
你也可以采用分阶段的方式来控制功耗:在设备上电后的前30秒内使用较短的间隔时间(如100毫秒)进行广告发送,之后再切换到较长的间隔时间(如1000毫秒)。Zephyr的广告发送API允许在发送广告的同时调整相关参数。
对于已建立连接的设备来说,可以利用“外设延迟”这一设置来进一步降低功耗。如果外设延迟设置为4,那么该外设可以在收到4次连接请求后才进行响应;这样一来,当连接间隔为50毫秒时,外设实际上每250毫秒才会被唤醒一次。因此,在不需要高吞吐量的情况下,可以向中央控制器申请更长的连接间隔时间:
static struct bt_le_conn_param conn_params = {
.interval_min = 80, /* 100毫秒(以1.25毫秒为单位) */
.interval_max = 160, /* 200毫秒 */
.latency = 4,
.timeout = 400, /* 4秒钟(以10毫秒为单位) */
};
/* 在连接建立后: */
bt_conn_le_param_update(conn, &conn_params);
通过调用bt_conn_le_param_update函数,可以向中央控制器发送参数更新请求。中央控制器会根据实际情况接受或拒绝该请求,不过大多数中央控制器都会允许合理的参数设置范围。
请启用Zephyr系统的电源管理功能:
CONFIG_PM=y
CONFIG_PM_DEVICE=y
当电源管理功能被启用后,每当没有线程需要运行时,内核会将处理器置于低功耗状态。
以nRF52840为例,其在空闲状态下消耗的电流会从大约3毫安降低到约1.5微安;这种差异是非常显著的。Zephyr的电源管理机制会自动选择系统在下次唤醒之前能够进入的最深睡眠状态。
在设备配置文件中,应禁用那些未被使用的外设。任何处于启用状态的外设(无论是UART、SPI还是I2C)即使在空闲状态下也会消耗电量。如果在生产环境中不需要使用UART功能(仅用于开发阶段的日志记录),就应该将其禁用:
&uart0 {
status = "disabled";
};
建议实际测量设备的电流消耗情况。像Nordic Power Profiler Kit II这样的工具能够以微安为单位实时显示电流消耗数值。Zephyr的CONFIG_THREAD_ANALYZER配置选项可以帮助你合理调整线程堆栈的大小——过度分配的线程堆栈会浪费RAM资源,从而导致更多的电能被消耗掉。
对于一个优化得当的BLE传感器来说,其平均电流消耗应控制在个位数微安的水平,这样使用纽扣电池就能使其持续运行多年。Zephyr平台确实能够帮助实现这一目标,但这就需要从无线电模块的调度机制、外设管理方式、时钟配置以及应用程序设计等多个方面入手进行优化。
Zephyr蓝牙与其他蓝牙开发框架的比较
在选择蓝牙开发框架时,您有多种选择。以下是Zephyr与其他框架的对比情况。
1. Zephyr与Nordic的SoftDevice
在转向使用Zephyr之前,Nordic公司使用的SoftDevice是他们自己开发的BLE开发框架。SoftDevice是一种预编译的二进制文件,用户无法阅读或修改其源代码。后来,Nordic推出了基于Zephyr的开源蓝牙开发框架nRF Connect SDK,取代了原有的SoftDevice。如果您要启动一个新的项目,建议使用Zephyr;因为SoftDevice已经属于过时的技术。
Zephyr与NimBLE(Apache Mynewt)
NimBLE是一种轻量级的开源BLE开发框架,最初来源于Apache Mynewt项目,后来也被移植到了ESP-IDF平台上。相比Zephyr,NimBLE的体积更小,因此可能更适合那些RAM资源非常有限的设备。不过,Zephyr的功能更为齐全(支持LE Audio、Mesh技术以及方向定位功能),在业界的应用范围也更加广泛。对于新开发的产品来说,除非您的设备对RAM的需求极其严格,否则Zephy尔才是更好的选择。
Zephyr与ESP-IDF的Bluetooth版本
Espressif公司的ESP-IDF为ESP32芯片提供了蓝牙开发框架(包括Bluedroid和NimBLE两种选项)。如果您只使用ESP32硬件,那么ESP-IDF确实是一个不错的选择。不过,Zephyr不仅支持ESP32芯片,还允许您将其移植到其他类型的硬件上,同时拥有统一的构建系统以及更丰富的BLE功能。因此,如果未来您可能需要更换芯片类型,Zephyr的跨平台性优势就会显得尤为明显。
Zephyr与厂商提供的带有私有源代码库的蓝牙开发框架
许多芯片厂商都会提供自己开发的BLE开发框架,但这些框架通常采用闭源技术。虽然这些框架使用起来比较方便,但会限制您只能使用该厂商提供的生态系统。而Zephyr则提供了跨平台性、开源代码以及庞大社区的共同支持,不过学习曲线可能会相对较陡一些。
对于那些需要在特定时间内完成开发、并且只使用某一款芯片的产品来说,厂商提供的开发框架可能更适合快速启动项目。但对于那些需要支持多种不同芯片类型、或者重视长期维护性的产品线而言,Zephyr无疑会是更优的选择。
下一步该怎么做
通过这份手册的学习,您应该已经掌握了BLE技术的基础知识,包括GAP广告协议、GATT服务、连接流程、通知机制、配对过程、Mesh网络技术、LE Audio功能以及功耗优化方法。此外,您也亲自编写了涉及这些概念的代码示例。接下来,您可以继续按照以下建议进行学习:
请探索Zephyr提供的蓝牙开发示例目录(路径为zephyr/samples/bluetooth/)。该目录中包含了30多个与Bluetooth相关的示例程序。其中,peripheral_hr示例实现了完整的心率监测功能;central示例展示了如何构建用于扫描或建立连接的中央节点;mesh/子目录中则提供了用于开发Mesh网络的应用示例,包括灯光控制、灯泡控制以及传感器应用;而bap_*系列示例则演示了LE Audio技术的实现方法。
请访问docs.zephyrproject.org/latest/connectivity/bluetooth/查阅Zephyr蓝牙相关文档。API参考手册涵盖了所有的函数、宏以及配置选项;而蓝牙架构文档则解释了主机层、控制器层与HCI层之间的交互机制。
如果您使用的是Nordic公司的硬件,那么就需要安装nRF Connect SDK。该开发工具在Zephyr的基础上增加了针对Nordic硬件的特定功能,包括蓝牙库、专有的无线通信协议(如ESB、Gazell),以及蜂窝调制解调器支持功能。它仍然使用相同的Zephyr内核和构建系统。
试着动手实践吧!可以制作一个用于控制台灯的BLE遥控器,或者开发一个能够将房间温度和湿度数据发送到手机的应用程序,亦或设计一款具有BLE连接功能的定制键盘,甚至还可以制作一个宠物追踪器。
巩固这些知识的最佳方式就是在实际硬件上遇到并解决各种问题。每个BLE产品都有一些独特的特性(例如与iOS系统或Android系统的连接参数协商机制、在连接断开后如何重新建立连接、如何设置MTU大小以优化数据传输效率),而只有通过实际开发才能了解这些特性。
BLE生态系统规模庞大且仍在不断发展中。Zephyr为开发者提供了一个高质量的开源平台,同时还有社区和企业的大力支持,因此这个技术在未来多年内都会得到持续的发展。现在,您已经具备了开始基于Zephyr进行开发的知识基础。
总结
本手册全面介绍了在Zephyr操作系统上进行BLE开发的相关内容,从基础知识到可投入实际生产的应用程序设计都涵盖了。
“BLE基础概念”部分为后续的学习建立了必要的理论框架:GAP负责控制设备的发现与连接过程;GATT定义了数据的结构与交换方式;服务与特性构成了设备提供的API接口;而UUID则用于唯一标识各个组件。这些概念并非Zephyr所特有,而是适用于所有基于BLE的技术栈。
在Zephyr平台上,我们逐步构建了越来越复杂的应用程序。示例中的信标功能展示了最基础的BLE配置流程——只需初始化相关模块并开始发送广告信息即可;LED服务通过读写特性展示了如何通过手机控制硬件设备;而通知功能则实现了实时数据推送;最后的传感器节点示例将广告发送、GATT数据交换、连接管理以及定期数据传输等功能整合到了一个完整的应用程序中。
您所开发的每一个BLE项目都会涉及到这些基本设计模式:广告数据的构造方式、GATT回调函数的编写规则、CCC配置的处理方法,以及连接状态的的管理机制。
标准的心率监测协议示例说明了如何使用SIG定义的16位UUID与Zephyr预定义的UUID宏进行集成,从而实现与通用BLE应用程序之间的互操作性。
“中心角色示例”则展示了BLE技术的另一面:如何扫描远程设备、建立连接以及发现对方提供的服务。在连接建立之后,MTU大小的调整和物理层协议的选取是优化数据传输速度和覆盖范围的两个关键因素,而这两项配置都需要通过Kconfig文件进行设置,并在运行时通过API接口来执行。
<除了点对点的连接方式外,蓝牙Mesh技术还将BLE扩展为多对多网络结构,从而可用于构建自动化控制系统以及大规模的物联网应用;而LE Audio技术则借助LC3编解码器及广播功能,代表了下一代无线音频技术。配对机制与安全措施能够有效保护生产设备免受未经授权的访问;功率优化功能、调试工具以及不同软件栈之间的对比分析,也为实际产品的开发提供了必要的支持。
<这里介绍的各种代码模板与Kconfig配置选项,为在Zephyr平台上开发各种BLE设备提供了有力的工具支持——无论是简单的信标设备,还是复杂的多协议网关,这些资源都足以满足开发需求。


