一. BLE设备角色定义
为了更好地理解如何优化 BLE连线过程,首先要对BLE Link Layer状态/角色一个基本的了解,才会对本文章更好的理解
链路层的运行可以通过一个包含以下状态的状态机来描述:
- 就绪状态(Standby)
- 广播状态(Advertising)
- 扫描状态(Scanning)
- 发起状态(Initiating)
- 连接状态(Connection)
- 同步状态(Synchronization)
- 等时广播状态(Isochronous Broadcasting)
链路层状态机规定同一时间仅允许一个状态处于活动状态。链路层应至少包含一个支持广播状态或扫描状态的链路层状态机实例,并可同时存在多个链路层状态机实例。
处于就绪状态(Standby)的链路层不发送或接收任何数据包。该状态可从任何其他状态进入。
处于广播状态(Advertising)的链路层将发送广播物理信道数据包,并可能监听和响应由这些广播数据包触发的回复。处于此状态的设备称为广播者。该状态可从就绪状态进入。
处于扫描状态(Scanning)的链路层将监听来自广播设备的广播物理信道数据包。处于此状态的设备称为扫描者。该状态可从就绪状态进入。
处于发起状态(Initiating)的链路层将监听特定设备的广播物理信道数据包,并通过响应这些数据包以发起与另一设备的连接。处于此状态的设备称为发起者。该状态可从就绪状态进入。
连接状态(Connection)可从发起状态或广播状态进入。处于此状态的设备称为处于连接中。
在连接状态内定义两种角色:
- 中央角色(Master或Central)
- 外围角色(Slave或Peripheral)
从发起状态进入时,连接状态应处于中央角色(Master或Central);从广播状态进入时,连接状态应处于外围角色(Slave或Peripheral)。
处于中央角色的链路层将与外围角色设备通信,并确定传输时序。
处于外围角色的链路层将与单个中央角色设备通信。
处于同步状态(Synchronization)的链路层将监听来自特定周期性广播设备、构成特定周期性广播序列的周期性物理信道数据包。该状态可从就绪状态进入。在此状态下,主机可指示链路层监听来自特定广播等时组(BIG)传输设备的等时数据包。处于同步状态且正在接收等时数据包的设备称为同步接收器。
处于等时广播状态(Isochronous Broadcasting)的链路层将在等时物理信道上传输等时数据包。该状态可从就绪状态进入。处于此状态的设备称为等时广播者。
二. 连接过程
声明:本次抓包本地的设备是一个蓝牙HOGP角色(可以理解为你家的电视机),对端设备是HIDS角色(也就是BLE蓝牙遥控器),操作步骤是本地HOGP去主动连接对端的遥控器,然后同步抓取了HCI log & Airlog,这次我们就进行一个硬核的对应分析
1. 广播者发送广播
其中我们查看SPEC,看看广播包的格式如下:
参数 | 含义 |
PDU Type | 描述了PDU的类型。4个bit, 定义如下: |
RFU | 预留供将来使用(Reserved for Future Use)。目前该位通常被设置为0。 |
ChSel | 通道选择位(Channel Selection)。指示是否使用特定的通道选择算法来选择下一个广告信道。 |
TxAdd | 发送地址类型位(Transmit Address Type)。指示发送方的地址类型是公有地址还是随机地址。如果是公有地址,该位为0;如果是随机地址,该位为1。 |
RxAdd | 接收地址类型位(Receive Address Type)。指示接收方的地址类型是公有地址还是随机地址。如果是公有地址,该位为0;如果是随机地址,该位为1。 |
Length | PDU数据字段的长度。表示紧随PDU头部的有效载荷数据的长度,以字节为单位。 |
所以对应的我们就能看懂air packet了
2. 扫描者发起连接
进入连接状态,有两种情况:
- 针对 Legacy 的情况:Initiator 在 primary advertising channel 上收到期望连接的 ADV,直接回复 CONNECT_IND,则本端进入 Connection State,成为 Master。
- 针对 Extended 的情况,Initiator 在 primary advertising channel 上收到期望连接的 EXT_ADV_IND,并在 secondary advertising channel 上接收到了 AUX_ADV_IND,并发送连接请求 AUX_CONNECT_REQ,对端回复 AUX_CONNECT_RSP,则本端进入 Connection State ,成为 Master。
这里需要说明一下,交互 CONNECT_IND 或者 AUX_CONNECT_REQ/AUX_CONNECT_RSP,这并不代表双方真正的建立起了,连接进入 Connection State,因为空口情况复杂,您并不一定能够保证在这个关键的交互时刻点,对方一定能够收到你发送的这个关键的数据包,万一对方没收到呢?的确存在这种情况(调试的时候,抓包也遇到过),那么怎么样才算是连接真正建立起来了呢?让我们细细道来:
1)首先需要明白的一点是,在建立连接时刻,Initiating 端成为 Master Role,ADV 端成为 Slave Role。
2)在一个连接事件中,总是 Master 先发包,Slave 收到 Master 的包,予以回复(Slave 没收到,就不会回复)
3)连接的维持条件是,相互都有包的交互,即便是不发送数据,也需要发送空包进行交互,以让双方知道,大家都还 Online。
OK,上述 3 点了解后,那么真正意义上的连接建立,是从发送(接收)到 CONNECT_IND 或者 AUX_CONNECT_REQ/AUX_CONNECT_RSP 后,的第一次成功的包交互。
那么万一比较糟糕的情况下,第一个包交互失败了呢?
Spec 定义,在建立连接的过程中(交互 CONNECT_IND 或者 AUX_CONNECT_REQ/AUX_CONNECT_RSP 完成开始算起),连续 6 个 Event 没有包的交互,算是建立连接失败。
基于以上背景,我们来介绍下latency的过程哈· 首先来看看CONNECT_IND
其中我们查看SPEC,看看CONNECT_IND的格式如下:
其中Payload格式如下:
其中2个byte的header我们已经在广播包中介绍过了,我们就来介绍下CONNECT_IND的payload部分
参数 | 含义 |
InitA | 发起连接设备的地址(Initiator Address),6 字节,标识发起连接请求的设备。 |
AdvA | 广播设备的地址(Advertiser Address),6 字节,标识正在广播并接受连接的设备 |
LLData | 连接数据字段,22 字节,包含连接参数和物理层配置信息。 |
AA | 访问地址(Access Address),4 字节,用于物理层帧的识别,由发起设备生成。 |
CRCInit | CRC 初始值(CRC Initialization Value),3 字节,用于数据包 CRC 校验的初始值。 |
WinSize | 连接事件窗口大小(Window Size),1 字节,表示连接事件窗口的时长(单位为 1.25 ms)。 |
WinOffset | 连接事件窗口偏移(Window Offset),2 字节,表示从广播事件结束到第一个连接事件开始的时间偏移(单位为 1.25 ms)。 |
Interval | 连接间隔(Connection Interval),2 字节,表示两个连续连接事件之间的时间间隔(单位为 1.25 ms)。 |
Latency | 从设备延迟(Slave Latency),2 字节,允许从设备跳过一定数量的连接事件而不丢失连接。 |
Timeout | 监督超时(Supervision Timeout),2 字节,表示连接被认为丢失之前允许的最大无通信时间(单位为 10 ms)。 |
ChM | 信道映射(Channel Map),5 字节,指示哪些数据信道被用于连接通信。 |
Hop | 跳频增量(Hop Increment),5 位,用于计算下一次连接事件使用的信道。 |
SCA | 睡眠时钟精度(Sleep Clock Accuracy),3 位,表示发起设备的睡眠时钟精度等级。 |
所以了解了这个,我们来看下空口的包
其中有几个点,需要注意下,我们来重点介绍下
a. 连接过程
现在我们来看看在空口中,连接是如何建立起来的,这里分为两部分阐述,先看 Master。
Master 是由 initiator 转移来的,是发送了 CONNECT_IND 或者 AUX_CONNECT_REQ 并且收到 AUX_CONNECT_RSP 后转型为 Master。连接建立初期的过程,尤为重要,因为他决定了是否能够成功建立连接,这是第一步。每个 CE 的第一个交互点,我们称之为锚点(anchor point),寻找锚点,是个细致的活,因为空口交互,均是以 us 为单位,马虎不得。
对于经典的来说,是由 CONNECT_IND 建立的,时序如下:
最开始是可连接的 Advertising packet,T_IFS(150us)后,接着 CONNECT_IND 包。
此刻,我们假设双方都正常的收到/发送了这个 CONNECT_IND,那么连接开始 SetUp。
- 经过 transmitWindowDelay
- 在经过 transmitWindowOffset
- 开窗 transmitWindowSize 发送第一个 packet
其中transmitWindowDelay的值为:
The value of transmitWindowDelay shall be 1.25 ms when a CONNECT_IND PDU is used, 2.5 ms when an AUX_CONNECT_REQ PDU is used on an LE Uncoded PHY, and 3.75 ms when an AUX_CONNECT_REQ PDU is used on the LE Coded PHY.
transmitWindowOffset 和 transmitWindowSize 是包含在 CONNECT_IND 内的,是由 initiator 给出的值,这些确定后,Slave 就在那个指定的位置去开窗收包即可。
我们先来看air log,确定下这个看法
可以看到Transmit Windows Offset是25ms,然后加上transmitWindowDelay是1.25ms,所以总共是26.25ms, 然后Transmit Windows Size是2.5ms,所以应该在26.25ms~28.72ms内发送数据,这个就是slave开接收的窗口。
我们可以看到我这这份空口是2.7185ms发送的数据
所以是符合预期的!
然后接着又遇到一个问题,是在哪个通道开窗口接收呢?就来 我们跳频序列的问题,这个空口是#1跳频,我们在下一个小节介绍
b. 跳频
跳频序列主要跟着三个参数有关,关于#1 跳频可以参考我这篇文章
https://wlink.blog.csdn.net/article/details/153415709
根据这几个参数,我们写到程序中
#include <stdio.h> #include <stdint.h> #include <stdbool.h> // 信道映射结构体 typedef struct { uint8_t channels[37]; // 所有37个信道的可用状态 uint8_t available_count; // 可用信道数量 uint8_t available_list[37]; // 可用信道列表 } channel_map_t; // 跳频算法上下文 typedef struct { uint8_t current_channel; // 当前信道 uint8_t hop_increment; // 跳频增量 channel_map_t channel_map; // 信道映射 } ble_frequency_hopping_t; // 初始化信道映射 void init_channel_map(channel_map_t *map, const uint8_t *available_ranges, uint8_t range_count) { // 首先标记所有信道为不可用 for (int i = 0; i < 37; i++) { map->channels[i] = 0; } // 标记可用信道 map->available_count = 0; for (int r = 0; r < range_count; r += 2) { uint8_t start = available_ranges[r]; uint8_t end = available_ranges[r + 1]; for (uint8_t ch = start; ch <= end; ch++) { if (ch < 37) { map->channels[ch] = 1; map->available_list[map->available_count++] = ch; } } } printf("可用信道数量: %d\n", map->available_count); printf("可用信道列表: "); for (int i = 0; i < map->available_count; i++) { printf("%d ", map->available_list[i]); } printf("\n"); } // 检查信道是否可用 bool is_channel_available(const channel_map_t *map, uint8_t channel) { if (channel >= 37) return false; return map->channels[channel] == 1; } // 通过重映射获取可用信道 uint8_t remap_channel(const channel_map_t *map, uint8_t unmapped_channel) { uint8_t index = unmapped_channel % map->available_count; return map->available_list[index]; } // 执行一次跳频 uint8_t next_channel(ble_frequency_hopping_t *hopping) { // 计算未映射的信道 uint8_t unmapped_channel = (hopping->current_channel + hopping->hop_increment) % 37; // 检查是否可用,如果不可用则重映射 uint8_t next_ch; if (is_channel_available(&hopping->channel_map, unmapped_channel)) { next_ch = unmapped_channel; } else { next_ch = remap_channel(&hopping->channel_map, unmapped_channel); } hopping->current_channel = next_ch; return next_ch; } // 打印跳频序列 void print_hopping_sequence(ble_frequency_hopping_t *hopping, uint8_t start_channel, uint8_t num_hops) { printf("跳频序列 (%d次跳频, HopIncrement=%d):\n", num_hops, hopping->hop_increment); printf("起始信道: %d\n", start_channel); printf("----------------------------------------\n"); hopping->current_channel = start_channel; for (int i = 0; i < num_hops; i++) { uint8_t unmapped = (hopping->current_channel + hopping->hop_increment) % 37; uint8_t next_ch = next_channel(hopping); printf("跳频 %2d: 计算值=%2d, 是否可用=%s, 最终信道=%2d\n", i + 1, unmapped, is_channel_available(&hopping->channel_map, unmapped) ? "是" : "否", next_ch); } } int main() { ble_frequency_hopping_t hopping; // 设置参数 hopping.hop_increment = 16; // 定义可用信道范围: 0-14, 27-36 uint8_t available_ranges[] = { 0, 36 }; uint8_t range_count = sizeof(available_ranges) / sizeof(available_ranges[0]); // 初始化信道映射 init_channel_map(&hopping.channel_map, available_ranges, range_count); // 计算并打印跳频序列 print_hopping_sequence(&hopping, 0, 10); return 0; }发现是符合我们计算的预期的,我们贴图看下第一包数据的channel index
c. Connection Events
Connection Event 我们简称 CE,代表着一个连接态的 Master/Slave 的交互集合。两个 CE 的起始时间之间的时间差,我们叫做 Connection Interval 也叫 CI。
所以,我们可以看到,在一个 Event 内的收发包,一定是 Master 先发起,Slave 回复。并且是交替进行。如果有数据,那么包体内就是 raw data,没有数据的话,双方也要在一个 Event 内,发送空包交互。
T_IFS = 150us
当双方建立起连接并维持连接的过程中,双方都会记录一个 Event Counter 的东西,顾名思义,这个值代表了当前这是第几个 Connection Event。他是一个 16bits 的数字,从 0x0000 - 0xFFFF.
好了,连接这些都建立上了,我们开始数据传送之旅。BLE 是周期性业务,所以每次的数据交互,只能在每个 Connection Event 中,那么一个 Connection Event 有多长呢?这个就涉及到一个概念,叫 MD。
Data Physical Channel PDU Header 的 MD 位用于指示设备有更多数据要发送。它的物理含义是,代表了当前的这个包后面还有没有跟进其他的非空数据包。如何理解呢?
比如,Master 想发 40 个 Bytes 的数据,(默认情况下,27 Bytes 为一个 Date Length),那么就要分为两包发送,第一次发送 27 Bytes,第二次发送 13 Bytes;那么在第一组包中的 Header 域中的 MD 字段,就要被设置成为 1,由于第二组发包后,发完了,那么第二组数据包的 Header 中的 MD 字段就被设置成为 0;
如果两个设备都没有在它们的数据包中设置 MD 位,则来自从设备的数据包关闭连接事件。
如果两个设备中的一个或两个都设置了 MD 位,主设备可以通过发送另一个数据包来继续连接事件,而从设备应该在发送其数据包后进行侦听。未能接收到一个数据包,或者在一个连接事件中接收到两个连续的具有无效 CRC 匹配的数据包将关闭该事件。
打个比如:如果双方都没有要传输的数据,那么 MD 都为 0,则这次的 CE 就提前结束,CE 就显得比较小。
类似于上面的例子。如果 Master 有 40 Bytes 的数据传送,那么 Slave 也要陪着 Master 玩,那么这时的 CE 就会较大:
当然,只允许 Master 传数据,那是不合理的,Slave 也可以发送据呀,当 Slave 发送据 Master 没数据的时候,Slave 的 MD 就会被设置成为 1,Master 也只有陪玩:
当然,Master 和 Slave 可以同时发数据,MD 都被设置成为 1:
这里我们总算是看出来了,MD 会影响到 CE 的长度;同时可看出来了,CE 最长不能超过 CI 的长度。那么有没有办法指定 CE 呢?当然可以,HCI 命令中,可以指定 CE MIN Length 和 CE MAX Length,如果一个 CE 装不了那么多数据,那么数据顺延到下一个 CI 去发就好,MD 照样置上。
所以 Core Spec 根据上面画的 4 个图的场景,根据 MD 的值,绘制了一个表格,来代表 MD 对 CE 的影响: