我们经常看到下图,读图时候我们会讲一个GATT Profile包含多个服务,一个服务包含多个特征,一个特征包含多个描述符,但是真正落到细节层面上,又有些模棱两可,比如为什么特征里面有个Declaration,又有个Value。当我们发数据时候是发给Declaration还是发给Value?

本文介绍GATT的技术细节。

1. Attribute

BLE 中的GATT 指Generic Attribute, ATT 指Attribute。

BLE 协议栈有ATT层和GATT层,ATT 层定义了一套基础数据结构,GATT 层基于ATT 层定义了一套通信交互规范。蓝牙协议规范的Vol 3的Part F和Part G有详尽介绍。

Attribute不能翻译成属性或特征,会与Property和Characteristic冲突,有的文档将其翻译成特性,读起来总觉词不达意,这里尽可能保持使用Attribute或其简写形式(ATT)。

ATT 是BLE 协议对现实世界实体的数据描述形式,不同的BLE 设备,其实就是它们的ATT 定义不同。

一个ATT 包含以下元素:

  • 类型(UUID)
  • 句柄(Handle)
  • 权限(Permission)
  • 值(Value)

UUID是一个16字节的唯一码,如果两个ATT 拥有相同的UUID,就认为它们是同一类ATT,因此使用UUID 来表征ATT 的类型。

蓝牙协议规范设定了一个UUID 模板(0000xxxx-0000-1000-8000-00805f9b34fb),第三四字节为占位符,这样只要拿一个双字节数填充占位符,即可产生一个16字节UUID。于是我们看到了许多双字节UUID。

常见的ATT 有:服务(Primary Service)、特征(Characteristic)和描述符。

他们拥有各自固定的UUID,比如服务的UUID为0x2800,特征的UUID为0x2803。

等下!特征的UUID不是用户自己定义的吗,为什么是固定的0x2803?

2. Declaration of Attribute

特征其实是个集合,特征包含以下子元素:

  • 特征声明(Characteristic Declaration)
  • 特征值声明(Characteristic Value Declaration)
  • 特征描述符声明(Characteristic Descriptor Declaration)

其中描述符是可选项,可能包含一个或多个描述符,也可能不包含描述符。

我们通常说:

BLE 的特征是BLE 设备与外界通信的接口,当手机与BLE 设备通信,其实都是与某个具体的特征进行读写。

这句话中的“特征”其实是指特征值。

特征值也是一种Attribute,所以特征值也有Value元素(Characteristic Value also has a value)。

在绝大多数情况下,我们说特征其实就是说特征值。 所以我们说特征的UUID,其实是特征值的UUID,它是用户自定义的。上面说特征的UUID为固定的0x2803,它其实是特征声明。

类似的,服务也是一个集合,服务包含:

  • 服务声明(Service Declaration)
  • 特征

上面说服务的UUID是固定的0x2800,其实是指服务声明(Service Declaration)的UUID。

3. Value of Attribute

服务声明的Value包含了服务的UUID。

所以,服务声明作为一个Attribute,它自己的UUID 是0x2800。它的Value 中包含一个UUID 值,这个UUID 是整个服务的UUID。这个UUID 可以是双字节,也可以是16字节。

特征声明的Value 包含特征值的属性、Handle 和特征值的UUID。

属性的可选项主要包括:Read(0x02), Write_no_resp(0x04), Write(0x08), Notify(0x10), Indicate(0x20),还有几个非常少见,这里略过。

一个特征可以有多个属性,通过或运算组合起来,比如Read + Write 属性,就是0x02 | 0x08 = 0x10。

句柄比较简单,无论服务还是特征,他们的句柄都是依次加1的。

这里特征值的UUID 也就是开发时候设定的UUID。

特征值的Value里面其实就是通信时候的传输的数据。

特征值的Value的数据长度是可变的,但是有个最大值限制,我们将这个限制称为MTU(Maximum Transmission Unit),特征值的Value数据长度最大为MTU – 3。如果MTU = 23,特征值的Value数据长度最大是23,如果MTU=247,则最大是244。

这个MTU也不是胡乱设定,如果链路层(Link Layer)支持长包特性,Data Length = 251,那么MTU可以取247,否则MTU就算取很大,在链路层仍然是以20字节小包拆开发送。

描述符的Value要根据描述符的类型,对于最常用的CCCD,如果Notify和Indicate都被禁用,它就是0x0000。

4. Permission of Attribute

当我们提起权限,总是会想起:只读权限、只写权限和读写权限。它们三个也是最常用的选项。

有个极端选项:NO_ACCESS,表示完全不能读写。

如果BLE 设备要求配对绑定,则读写权限需要与以下几种选项产生搭配:

  1. No_Encryption
  2. Encryption_no_MITM
  3. Encryption_with_MITM

No_Encryption 表示不在乎配对与否。

Encryption_no_MITM 表示设备需要建立配对关系,但是不强求MITM。它对应采用Just Works配对方式。

Encryption_with_MITM 表示要求设备建立MITM的配对关系。它对应采用Passkey配对方式。

5. 示例

往nRF52开发板中下载一个ble_app_blinky 的固件,用手机连接它,它的GATT服务长这个样子:

它包含三个服务,前两个服务是默认的服务,这里只关注第三个服务。

在芯片内存中,第三个服务的各项数据均保存下来,以表格的形式列出来:

GATT AttributeHandleUUIDValuePermission
Primary Service Declaration0x000B0x280000001523-1212-EFDE-1523-785FEABCD123Read
Characteristic Declaration0x000C0x28030x12, 0x000D, 00001524-1212-EFDE-1523-785FEABCD123Read
Characteristic Value0x000D00001524-1212-EFDE-1523-785FEABCD123User DataRW
Descriptor (CCCD)0x000E0x29020x0000RW
Characteristic Declaration 0x000F0x28030x0A, 0x0010, 00001524-1212-EFDE-1523-785FEABCD123Read
Characteristic Value 0x001000001525-1212-EFDE-1523-785FEABCD123 User DataRW

第二行是服务声明,它的Handle是0x000B,因为第一个服务的Handle是0x0001,依次递增下来就是它。它的类型UUID是0x2800,表明它是一个Primary Service。从Value中可以读出这个服务的自定义UUID,它是一个Blinky的UUID。

第三行到第五行是Button特征,第六行是LED特征。

第三行是特征声明,观察它的Value,0x12等于(0x04 | 0x08),他表示该特征具有Read(0x04)和Write(0x08)两个属性。第二个数0x000D表示特征值的Handle,它比特征声明的Handle大1。第三个数是特征值的UUID。

第五行是一个CCCD(Client Characteristic Configuration Descriptor)描述符,它是Notify和Indicate属性必须的描述符。因为它太常见、所以它的UUID(0x2902)值得记住,能在许多时候帮助我们快速定位到它。

(完)

本文基于pc-ble-driver v4.1.1,介绍如何在Windows上搭建开发环境。

以前写过一个入门教程(链接),最近的版本更新改动很大,那篇文章中介绍的方法已经过时。我仍然建议预先读它,然后再看本文。

pc-ble-driver是什么

它是一个PC端的BLE开发平台。

我们在开发板中烧录一个中间层固件,通过PC给固件发指令,与外部BLE设备(比如手机或一个BLE从设备)进行通信。

PC端可以用C/C++、Python、Javascript 语言进行编程。本文介绍的方案是基于C语言。

pc-ble-driver 部署在github中,可以从该页面获得它的全部信息。

生成静态库文件

首先要安装Visual Studio 2019,它有多个开发语言,我们这里只需要C++。

安装好以后,我们能够打开VS的项目文件(.sln)和工程文件(.vcxproj),同时也得到了一个编译工具MSBuild.exe,它的路径应该在:C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe

然后安装cmake工具。

下载pc-ble-driver的源文件:https://github.com/NordicSemiconductor/pc-ble-driver/releases

页面中有多个选项:

  • nrf-ble-driver-4.1.1-win_x86_32.zip
  • nrf-ble-driver-4.1.1-win_x86_64.zip
  • Source code (zip)
  • Source code (tar.gz)

需要下载Source Code (zip)那一项。

将源文件解压到一个开发目录下,我这里是:C:\repo\pc-ble-driver-repos\pbd

开始之前需要明确我们要干什么!

pc-ble-driver 在PC端实现了一个库,这个库可以调用softdevice,实现BLE的功能。我们写的代码直接调用这个库文件暴露的接口。

在上面的下载页面,下载nrf-ble-driver-4.1.1-win_x86_64.zip文件,里面就包含了这个库,理想情况是我们能够拿它直接使用,但奇怪的是我这里使用它的库会报错。无奈只能自己生成一个库来用。

所以我们的目标是,编译源文件,生成一个静态库文件(.lib)。

下载必要的第三方VC库:

  • asio
  • catch2 (只有一个catch.hpp文件)
  • spdlog

将它们放在目录:C:\repo\pc-ble-driver-repos\pbd\ext

注意,官方页面上使用vcpkg来下载它们,还要设置一堆环境变量,把问题复杂化,这里我们直接下需要的库,不管vcpkg。

在pbd下面创建一个build文件夹: C:\repo\pc-ble-driver-repos\pbd\build。打开cmd窗口,cd到该文件夹,然后执行:

cmake -DASIO_INCLUDE_DIR=C:\repo\pc-ble-driver-repos\source\ext\asio-1.12.2\include -DCatch2_DIR=C:\repo\pc-ble-driver-repos\source\ext\catch-2.10.0 -DCONNECTIVITY_VERSION=4.1.1 -DNRF_BLE_DRIVER_VERSION=6.1.1 -G "Visual Studio 16 2019" -A Win32 ..

值得看一下各个参数:

  • -DXXX表示增加一个变量,也可以通过设置环境变量的方式进行(有坑,不推荐)
  • CONNECTIVITY_VERSION和NRF_BLE_DRIVER_VERISON 按实际版本进行配置,因为可能过几天又有新版本了
  • -G “Visual Studio 16 2019″,这个跟官方页面上的-G Ninja不同,我发现用Ninja有坑
  • -A Win32表示生成Win32的库,可选项还包括:x64, ARM, ARM64。(链接)如果这里使用了Win32,后面创建VS工程时候,也得使用Win32。

命令执行完毕,会在build下生成大量文件,我们关心的是:项目文件(nrf-ble-driver.sln) 和好几个工程文件(*.vcxproj)。

这时候可以双击打开sln项目文件,或者用命令:

"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe" nrf-ble-driver.sln

编译完毕,在pbd\build\Debug目录下就能看到多个lib文件,找到:nrf-ble-driver-sd_api_v6-mt-static-gd-6_1_1.lib,它就是我们生成的静态库文件,后面我们利用它来编写代码。

搭建开发环境

在C:\repo\pc-ble-driver-repos目录下新建几个目录:

  • include
  • lib
  • project\hrs和project\hrs_c
  • hex

将pbd\include文件夹下的东西复制到这个include中。其实只复制common和sd_api_v6两个文件夹即可。

将刚才生成的 nrf-ble-driver-sd_api_v6-mt-static-gd-6_1_1.lib 复制到lib中。

将pbd\hex\sd_api_v6下的connectivity_4.1.1_1m_with_s132_6.1.1.hex和connectivity_4.1.1_1m_with_s140_6.1.1.hex复制到hex中,二者分别用于nRF52832和nRF52840芯片。

project\hrs用于放HRS示例工程,project\hrs_c用于放HRS_C示例工程。将 pbd\examples\heart_rate_monitor\main.c 复制到hrs目录下,将 pbd\examples\heart_rate_collector\main.c 复制到hrs_c目录下。

下面介绍如何制作一个HRS工程。

在Visual Studio 2019中,新建一个空白工程,执行以下操作:

先添加main.c到工程。

在工程设置C/C++ -> Additional Include Directories中,添加

  • C:\repo\pc-ble-driver-repos\include\sd_api_v6
  • C:\repo\pc-ble-driver-repos\include\common
  • C:\repo\pc-ble-driver-repos\include\common\config
  • C:\repo\pc-ble-driver-repos\include\common\internal
  • C:\repo\pc-ble-driver-repos\include\common\internal\transport
  • C:\repo\pc-ble-driver-repos\include\common\sdk_compat

C/C++ -> Compile AS设置为Compile As C Code。

C/C++ -> Precompiled Header File设置为Not Using Precompiled Headers

C/C++ -> Process Definitions添加:

  • WIN32
  • _WINDOWS
  • NRF_SD_BLE_API=6
  • _WIN32_WINNT=0x0502
  • NOMINMAX
  • ASIO_STANDALONE
  • SD_RPC_EXPORTS
  • HCI_LINK_CONTROL
  • _CRT_SECURE_NO_WARNINGS
  • PC_BLE_DRIVER_STATIC
  • CMAKE_INTDIR=”Debug”
  • _DEBUG
  • _CONSOLE

Linker -> Additional Dependencies添加:C:\repo\pc-ble-driver-repos\lib\nrf-ble-driver-sd_api_v6-mt-static-gd-6_1_1.lib

Linker -> Generate Debug Info改成:Debug Information optimized for faster links (/DEBUG:FASTLINK)

保存好后打开main.c,按F7编译一下,如果不报错就说明一切完好,可以连上开发板测试了。

hrs_c工程设置过程与之一模一样。

可以从这里下载我配置好的工程作为参考:https://gitee.com/isyq/pc-ble-driver-demo

测试HRS工程

准备一个52开发板,连上电脑。擦除并烧录C:\repo\pc-ble-driver-repos\hex\connectivity_4.1.1_1m_with_s132_6.1.1.hex

查看开发板的串口号,比如是COM58,那么在main.c中,找到DEFAULT_UART_PORT_NAME定义,将其修改为COM58。

然后在VS中按F5进行调试,就会打开有一个cmd窗口,通过打印的内容可以看到它正在广播,如下:

这时候用手机与它连接,发现可以连接但是无法正常发现服务。这是因为这个main.c没有处理Data Length Update事件,导致超时断开。

解决办法是,找到ble_evt_dispatch()函数,增加处理该事件,代码如下:

case BLE_GAP_EVT_DATA_LENGTH_UPDATE_REQUEST:
{
    ble_gap_data_length_params_t dl_params;

    memset(&dl_params, 0, sizeof(ble_gap_data_length_params_t));
    err_code = sd_ble_gap_data_length_update(adapter, p_ble_evt->evt.gap_evt.conn_handle,
        &dl_params, NULL);
    if (err_code != NRF_SUCCESS)
    {
        printf("Data len update error: %x\n", err_code);
        fflush(stdout);
    }
} break;

然后手机就可以跟它进行连接和通信了。

值得注意的是,pc-ble-driver与nRF5 SDK 同根同源,这个版本直接使用SDK 15.3的softdevice,但是二者的编程模型不同,SDK 中使用了大量的XXX_DEF 和XXX_OBSERVER 的编程模型,许多BLE库也都是基于这个实现的,在pc-ble-driver中则完全无法使用,所有的扫描、连接、发现操作都得用最原始的API去实现,给使用带来了不少难度。

另外,pc-ble-driver 与PC 之间使用串口进行通信,尽管采用了1M的比特率,但是仍然不同于单芯片方案,有诸多限制。pc-ble-driver 不支持data length extension, data length只能使用27, 意味着它的通信速度跑不起来。

(完)

BLE 主从设备建立连接以后,先执行发现服务和特征的过程(Discovery Procedure),然后对着指定特征执行数据收发。

本文以一个Nordic LED Button服务为例(如下),介绍服务和特征发现过程的协议细节。

1. 发现服务

对于主机而言,首先找到目标服务的句柄范围,然后再在句柄范围内找下面的特征。

发现服务有两个方案:

  • 发现全部服务
  • 发现指定UUID的服务

1.1 发现全部服务

发现一个服务的基本思路是搜索Primary Service的UUID(0x2800),如果找到,即可获得它的句柄范围。

第一次我们从0x0001开始搜索,找到一个服务后,从该服务的句柄末尾开始继续搜索,可以找到第二个服务的句柄范围。

反复执行发现服务操作,就可以依次找到全部服务的句柄范围。

执行发现服务时,GATT协议层主机会产生一个请求(Read By Group Type Request),从机收到后返回一个响应(Read By Group Type Response)。

(为了说明一些问题,这个图片不对应上面的Nordic LED Button服务)

图中,请求包中包含了起始和末尾的句柄,第一次发现默认是0x0001~0xFFFF。

响应帧中包含了两个服务的UUID,代表它发现的两个服务。(为啥一次性发现两个?原因未知,它仅限于Generic Access和Generic Attribute)。

第二次的响应帧就比较正常,返回了发现的一个服务UUID。如果检查该数据帧的细节,能够看到该服务的起始和结束UUID。

SDK 15.3中执行发现全部服务的函数是: sd_ble_gattc_primary_services_discover(uuid),注意要将uuid参数设置为NULL。 在该函数的回调事件中即可获得发现的服务的UUID和句柄范围。

1.2 发现指定UUID服务

发现指定UUID的服务,顾名思义,除了查找0x2800,还查找指定的UUID。

通过它可以快速找到目标服务,对于不关心的服务项可以快速略过,加快整体发现过程的速度。

当执行发现指定UUID服务时,主机发出一个请求(Find By Type Value Request),从机返回一个响应(Find By Type Value Response)。

观察请求包,它包含了一个UUID的输入参数。响应包中,返回数据中的11就是该服务的句柄。

SDK 15.3中执行发现全部服务的函数是: sd_ble_gattc_primary_services_discover(uuid),注意要将uuid参数设置为目标UUID。 在该函数的回调事件中即可获得发现的服务的UUID和句柄范围。

值得一提的是, 如果你做一个nRF Connect APP这种通用性主机,肯定要使用发现全部服务。对于一个特定的主机,比如针对这个Blinky从机做的主机,我们知道Blinky服务的UUID,则更适合选择第二种方案。

2. 发现特征

发现特征一定是发现某个指定服务下面的特征,所以一定要先发现服务,再发现特征。

现在我们有了一个服务的句柄范围,比如0x000B ~ 0x0010。

与发现服务类似,发现特征也有两个方案:

  • 发现全部特征
  • 发现指定UUID的特征

发现特征时,主机在服务的句柄范围内搜索特征声明的UUID(0x2803),一旦找到就返回响应,响应中包含了特征的关键信息,比如:特征的句柄范围,特征值的UUID,特征的属性、权限等。

根据特征的句柄范围,我们能够判断服务下面的特征是否都被发现,如果没有,则需要重复执行发现特征,直到所有的特征都被发现。

发现特征时,主机发出一个请求(Read By Type Request ),从机返回一个响应(Read By Type Response)。

图中,从响应包中可以看到该特征的属性(read, Notify),句柄(13)和UUID。

在SDK 15.3中,可以利用sd_ble_gattc_characteristics_discover()来执行发现全部特征的操作。

但是很遗憾,没有一个API能够发现指定UUID的特征,只能一个一个的发现。

3. 发现描述符

如果特征具有Notify或Indicate属性,它一定携带一个CCCD描述符。

我们在发现特征的时候检测其属性,如果属性包含Notify或Indicate,则执行发现描述符的过程。

发现描述符相对简单,搜索描述符的UUID(0x2902或其他),并在响应包中给出其句柄和UUID信息。

发现描述符时,主机发出一个请求(Find Information Request),从机返回一个响应(Find Information Response)。

通过这几个步骤,就可以完整的发现目标服务和特征。

如果服务下面有多个特征,一个一个的发现会非常繁琐,代码上需要进行大量的判断,代码逻辑必然不简单,所以SDK 提供了一个库来完成这些繁冗的操作,它就是ble_db_discovery库。

如果理解了上面这套发现过程,使用ble_db_discovery必然能驾轻就熟,反过来则很可能搞不懂它的设计思路并在使用中产生疑惑,这也是本文产生的原因。

(完)