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必然能驾轻就熟,反过来则很可能搞不懂它的设计思路并在使用中产生疑惑,这也是本文产生的原因。

(完)

我们经常看到下图,读图时候我们会讲一个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)值得记住,能在许多时候帮助我们快速定位到它。

(完)

前面文章分析了OTA的实现架构、协议和存储问题,提到了一个细节,如果使用双程序更新模式,将Flash空间分成上下两部分,如下图所示,那么最后生成的BIN文件为了放在不同的位置,需要生成两份地址不同文件,然后根据芯片的实际使用情况,选择合适的文件进行烧写。

BLE OTA memory layout

偶然看到一个信息,Nordic做双程升级时候,只需要一个二进制文件,不需要提供两个BIN文件进行选择。Nordic是怎么办到的呢?

内存布局

Nordic芯片的Flash布局图如下:

Nordic_DFU_Memory_Layout

Flash地址从下向上增长,最底层的MBR(Master Boot Record)区域存放中断向量表的位置。芯片中的Bootloader、SoftDevice和Application都具有自己的向量表,MBR的寄存器UICR.NRFFW[1]指示系统该选择哪一个中断向量表。(参考

SoftDevice区域存放协议栈。

Application区域存放用户固件程序。

APP Data区域存放用户希望永久保存的数据,比如设备地址等。这块区域在升级过程中也可以保持不被擦写。

DFU Bootloader区域存放Bootloader程序,用以执行升级过程。

更新方案

Nordic提供了三种更新方案,分别是:(参考

  1. 双程DFU
  2. 单程DFU
  3. 更新BootLoader和SoftDevice

1. 双程DFU

双程DFU方案的操作流程如下:

Dual-Bank DFU memory layout

从左侧图中可以看到Application区域空间分成了两部分:Blank 0和Blank 1。

假如当前的程序放在Blank 0区域,那么新的程序将被复制到Blank 1区域,如中间的图。

大多数芯片厂的DFU过程到这里就结束了,只要通过Bootloader引导MCU进入到新的程序地址即可。这样必然会产生本文开头提到的问题,在升级程序的时候,需要确定旧的程序放置位置。由于生成的BIN文件包含了起始地址信息,这意味着要新的程序要准备两份功能相同、地址不同的BIN文件,按照实际情况选择合适的文件烧写空白区域。

Nordic使用了一个小技巧,即将新的程序(Blank 1)复制到旧的程序空间(Blank 0),这样就是的程序基地址永远在Blank 0,从而解决了上述困难。

由于Flash赋值是一个耗时操作,DFU做起来一定会很慢。仔细观察,会发现双程DFU至少需要执行两次擦除和两次烧写,分别是:

  • 烧写Blank 1
  • 擦除Blank 0
  • 烧写Blank 0
  • 擦除Blank 1

疑问:如果在执行右侧图的Flash赋值过程中途,发生了断电,该怎么办?

我猜测应该先将Blank 1的内容复制到Blank 0,当复制完毕校验通过,再擦除Blank 1,结束DFU过程。这样即使在复制中途断电,Blank 1的内容仍然有效,重启后可以重新进行复制操作。读源码应该可以确认这一点。

2. 单程DFU

单程DFU的操作流程如下:

Single-Blank DFU memory layout

单程DFU就是现将Application区域清空,再将新的程序写入到Application空间的基地址。

这种做法好处是充分利用Application的空间,而且只需要擦除一次,烧写一次,操作时间相对于双程DFU一定会有大幅度降低。

单程DFU的缺点是不能在线更新,因为用户程序需要被擦除,所以一定需要断开连接和重启。

3. BootLoader和SoftDevice

升级BootLoader和SoftDevice的操作流程如下:

BootLoader and SoftDevice DFU memory layout

这个图乍一看比较复杂。

(1)擦除旧用户程序

(2)将新的Bootloader和SoftDevice写入Application区域

(3)将新的Bootloader和SoftDevice复制到Bootloader和SoftDevice区域

So easy? ——不是的!

并不是每个厂商的芯片都能实现这个功能,因为第(2)步到第(3)步之间,MCU要擦除Bootloader区域,有些BLE厂商的MCU,Bootloader程序是唯一的启动器,Bootloader程序自身是不可以被擦除的,否则MCU无法正常启动。Nordic的芯片存在MBR,利用MBR引导MCU跳转到Bootloader或其他地方。

程序在第(2)步结束时设置MBR的寄存器,让MCU下次启动时候不直接跳转到Bootloader区域,而是跳转到Application区域,执行一个复制操作,将新Bootloader程序和SoftDevice程序复制到各自区域,然后再设置MBR寄存器,让MCU下次重启时候直接跳转到Bootloader区域。

虽然仅仅增加了几行Flash来存放MBR信息,但是对MCU的更新提供了极大的灵活性。

(完)