1. Home
  2. 博客

    低功耗蓝牙基础课程第三课:低功耗蓝牙连接

低功耗蓝牙基础课程第三课:低功耗蓝牙连接

Nordic Semiconductor

课程概述

在低功耗蓝牙中,实现双向通信最常用的方式就是建立连接。除此之外,连接还可以带来诸多优势,例如更高的数据吞吐量、丢包处理机制以及更强的安全性。

当低功耗蓝牙设备进入连接状态后,就会使用一组连接参数。对于简单的应用场景,默认连接参数就可以很好地工作;但你也可以微调这些参数,使其最大程度适配你的应用,实现最低功耗,或是获得你所需要的吞吐量。

在本课中,我们将讲解这些参数的作用,以及该如何对它们进行配置。我们仅从外设(peripheral)的视角讲解连接,同时介绍该视角的含义以及其所存在的限制。

在本课的实验环节中,我们将在你的 Nordic 设备与智能手机之间建立双向通信。之后练习读取连接参数,并向中心设备请求修改连接参数。最后,我们将练习在建立连接的过程中请求切换为其他 PHY(物理层)模式。

课程目标

  • 理解低功耗蓝牙的连接过程
  • 学习各类连接参数及其作用
  • 学习如何修改连接参数
  • 通过动手实操练习,掌握建立连接以及修改各类连接参数的方法

连接过程

在上一课中,我们讲解了低功耗蓝牙的广播流程:外设通过广播宣告自身的存在,从而被中心设备发现,并最终与中心设备建立连接。

建立连接

建立连接需要两台设备:一台作为正在执行广播的外设,另一台作为正在执行扫描的中心设备。当中心设备接收到来自外设的广播数据包时,就可以发起连接。通常情况下,中心设备会先解析广播数据包的内容,再据此决定是否发起连接。当中心设备发出连接请求之后,外设与中心设备之间便建立起一条面向连接的双向通信通道。

如上图所示,正在发送可连接广播的外设,在每一次广播之后都会开启一段短暂的接收(RX)窗口,用来侦听传入的连接请求。虽然我们称之为连接请求,但实际上外设无法自行选择接受或是拒绝该连接请求。除非启用了接收列表过滤器,否则外设必须始终接受连接请求。后续如果外设不希望继续保持连接,则可以随时主动断开与中心设备的连接。

补充说明

使用接收列表过滤器(旧称白名单),可以让外设限制仅允许指定设备向其发送连接请求。该内容将在第 5 课进行讲解。

连接建立之后

在外设成功收到连接请求数据包之后,两台设备便进入连接状态。建立连接后,设备将不再使用广播信道(37、38、39 信道),转而开始使用数据信道(0‑36 信道)。为降低连接过程中的干扰并提升吞吐量,低功耗蓝牙采用信道跳频机制,也就是频繁切换数据传输所使用的信道。这样一来,如果所处环境中部分信道存在较强噪声,数据包就可以在下一个连接间隔内切换至其他信道进行重传。为保证数据完整性,低功耗蓝牙传输的所有数据包都会无限次重试发送,直至收到应答或者连接终止。

低功耗蓝牙的连接特性,是设备能够实现低功耗的主要原因。处于连接状态时,两台设备大部分时间都处于休眠状态。为实现该机制,双方会协商多久唤醒一次进行通信。其余时间,设备关闭无线射频、设置定时器然后进入休眠。双方协商确定的休眠时长称为连接间隔,该参数在初始建立连接时设定;而每次设备唤醒并开展通信的动作,即为连接事件,每经过一个连接间隔就会发生一次连接事件。

定义

连接间隔:已建立连接的两台设备唤醒并交换数据的时间间隔。

连接事件:每经过一个连接间隔就会触发,由中心设备向外设发送数据包。

下图展示了典型的连接工作过程。中心设备与外设都会在每一个连接间隔唤醒,执行连接事件并传输数据。

连接间隔最初由中心设备在连接请求数据包中进行设置,不过在连接建立之后该参数是可以修改的。如果需要传输大量数据,两台设备可以在每一个连接间隔之内发送多个数据包;但一旦停止发送数据,若后续还有数据要发送,则必须等待下一次连接事件。即使没有有效业务数据需要发送,通信双方也需要发送空数据包来完成时钟同步。倘若待发送的数据量超出单个连接间隔所能处理的时间,则数据将会被拆分,分散在多个连接间隔中完成传输。

断开连接

两台设备建立连接之后,如果不发生任何异常情况,连接将会一直保持。连接终止(也就是设备断开连接)有两种方式:

  • 由应用程序触发断开
  • 由监管超时(supervision timeout)触发断开

由应用程序触发断开

两台设备中的任意一方都可以发送终止数据包,使连接断开。例如,设备不再希望与对方保持连接时就可以执行该操作;当连接出现异常时同样也会触发断开。举个例子:当前已连接的设备声称自己是此前曾经连接过的设备,却无法使用正确的加密密钥对数据包进行签名。终止数据包内包含一个名为 “断开原因” 的字段,用于说明设备断开连接的缘由,例如是用户主动发起断开,还是协议栈出现了问题。

由监管超时触发断开

设备断开连接的另一种情况是对方停止响应数据包。造成该现象有多种原因:已连接设备上的应用程序发生崩溃并复位(这种情况并不少见,尤其在开发阶段)、已连接设备电量耗尽,或是设备被移出无线通信范围。连接发生超时所需要的时长由连接监管超时参数设定,我们将在下一小节对该参数进行更为详细的讨论。

连接参数

当外设与中心设备建立连接时,双方会协商一组连接参数。出于向后兼容的考虑,部分参数拥有默认初始值;而另外一部分参数则由中心设备决定,并携带在连接请求数据包当中。

上一小节已经简要介绍过连接间隔与连接监管超时。除这两个参数之外,还有外设延迟(peripheral latency)同样由中心设备在连接请求数据包中进行设置。外设延迟的作用是:当外设没有数据需要发送时,可以选择跳过部分连接事件,无需唤醒。

出于向后兼容,无线模式(1M、2M 或者编码 PHY)默认设置为 1M,但可以在连接存续期间修改。数据长度以及 MTU(最大传输单元)同样采用兼容的默认配置;本小节的实验部分,我们将会学习如何修改这两项参数。

连接间隔

低功耗蓝牙设备大部分时间都处于 “休眠” 状态(这也是名称中 “低功耗” 的由来)。在连接状态下,该休眠机制依靠协商连接间隔来实现,连接间隔规定了两台设备相互通信的频次。通信完成之后,设备关闭无线射频、设置定时器并进入空闲模式;当定时器计时结束,两台设备会同时再次唤醒并进行通信。该机制由低功耗蓝牙协议栈完成底层实现;而具体希望设备以何种频率进行通信,则需要由应用层通过设置连接间隔来决定。

监管超时

两台设备建立连接之后,会协商一个参数,该参数用于设定:自上一次成功收到数据包之后,经过多长时间仍未收到报文,设备就判定连接已经丢失。这个参数就叫做监管超时(supervision timeout)。

因此,如果其中一台设备意外关机、电量耗尽,或是两台设备超出无线通信范围,从成功收到最后一个数据包开始计时,经过这段时长之后,设备就会认定连接已经丢失。

外设延迟

外设延迟允许外设在没有任何数据需要发送时,可以跳过一定数量的连接事件,不必唤醒。通常情况下,连接间隔需要在功耗与通信低延迟之间做严格取舍。如果你希望降低延迟,同时又维持较低的功耗,就可以使用外设延迟。

该参数在 HID(人机接口设备)应用场景下尤其实用,例如电脑鼠标与键盘。这类设备大部分时间没有数据上报,但是一旦有数据需要发送时,则要求通信延迟必须很低。借助外设延迟这项配置,设备可以在多个连接间隔期间保持空闲休眠,以此降低功耗,同时还能够保证较低的通信延迟。

PHY 无线模式

标准低功耗蓝牙(1M PHY)的传输速率为 1 Mbps。而在 Bluetooth 5.0 中,新增了高速(2M PHY)以及远距离(编码 PHY)两种无线模式(相关内容参见《PHY:无线模式》),由此我们又多了两种选择。

首先,我们可以更改调制方式,使用 2 Mbps 以获得更高的传输速率。这会带来两种效果:既可以更快完成数据传输,随即快速回到休眠状态,从而进一步节省功耗;也可以利用节省出来的时间传输更多的数据,几乎可以将低功耗蓝牙连接的吞吐量提升一倍。不过代价是通信距离会略微缩短。

另一种方案是使用编码 PHY,该模式可以大幅提升通信距离,但代价是吞吐量有所下降。

数据长度与 MTU

数据长度和 MTU(最大传输单元)是两个不同的参数,但二者通常密切配合工作。

MTU 指单次 GATT 操作(例如一次发送操作)所能传输的字节数;而数据长度是单个低功耗蓝牙数据包可以发送的字节数。MTU 的默认值为 23 字节,数据长度的默认值为 27 字节。当 MTU 大于数据长度时(例如 MTU 为 140 字节,而数据长度为 27 字节),数据就会按照数据长度的大小进行分片。也就是说,在应用层看来只发送了一条消息,但在空中传输时,数据实际上被拆分成了多个更小的数据段。

理想情况下,我们希望全部数据都通过单个数据包发送,以此缩短数据传输耗时。因此蓝牙 4.2 版本引入了数据长度扩展(DLE,Data Length Extension),使得数据长度可以从默认的 27 字节最高增加到 251 字节。将所有数据封装到同一个数据包还可以减少空中传输的总字节数,因为每个数据包都自带一个 3 字节的头部。这既节约时间、降低功耗,进而也可以提升低功耗蓝牙连接的吞吐量。

数据长度与 MTU 之间并非一一对应。在空中传输时,链路层的数据长度最大可达 251 字节,但上层能够发送的实际有效载荷最多只有 244 字节。这是因为这 251 字节的数据 PDU 载荷当中,需要预留 4 字节的 L2CAP 头部以及 3 字节的属性头部。于是:251 − 4 − 3 = 244 字节,剩下的这部分空间才可以用来存放实际的应用载荷数据。

MTU 与数据大小之间的关系

下图展示了发送一条 40 字节消息时,修改默认数据长度前后的对比情况。显而易见,将全部数据放在一个数据包中发送,可以缩短无线射频的开启时间。

左侧:未启用数据长度扩展,40 字节有效载荷。右侧:启用数据长度扩展,40 字节有效载荷。来源:Online Power Profiler 工具

更新连接参数

连接间隔、监管超时以及外设延迟由中心设备决定,但外设可以发起修改请求。不过对于这类请求,最终决定权始终归属于中心设备。因此,如果中心设备是手机,则由手机上运行的操作系统决定接受还是拒绝该连接参数请求当中的新参数。

至于 PHY 无线模式、数据长度以及 MTU,则不能仅由中心设备单方面选定。由于修改这部分参数的功能是在后续版本的蓝牙规范中才新增的,所以在连接刚建立时,这些参数一律使用默认值。连接建立之后,任意一方设备都可以发起请求,更新上述参数。对端设备随后会回复自身所支持的参数取值,或者声明不支持其中一项或多项参数的更新。

以数据长度为例,连接刚建立时该值始终为 27 字节。假设外设希望将其更新为 200 字节,并就此发送更新请求。之后中心设备可能回复消息,表示自身最大仅支持 180 字节,于是双方协商确定数据长度设置为 180 字节。

PHY 无线模式的默认值为 1M,MTU 的默认值为 23。

练习 1 – 连接至你的智能手机

在上一课中,我们使用 Nordic 开发板开启广播,再用手机扫描该广播。当时你可以通过 nRF Connect for Mobile 应用连接上开发板,但除此之外没有发生其他动作。

在本练习中,我们将把你的 Nordic 开发板作为外设、智能手机作为中心设备,在二者之间建立连接。接着我们会配置若干回调函数,以便在连接参数发生变更时收到通知。之后我们将添加一个临时服务,从而可以借助已经建立好的连接发送数据。

练习步骤

本课程的 GitHub 代码库中,找到本练习的基础代码,路径为 l3/l3_e1。

你可能会注意到,本练习使用上一个练习(第 2 课练习 3)最终得到的应用程序作为基础代码。

1. 引入用于处理连接事件的头文件

首先添加处理低功耗蓝牙连接所需的头文件。请在 main.c 文件靠近顶部的位置添加下面的代码。

#include <zephyr/bluetooth/conn.h>

2. 设置在连接建立或者断开时触发的回调函数

2.1 声明连接回调结构体

我们需要声明一个类型为 bt_conn_cb、名称为 connection_callbacks 的结构体。

该结构体用于跟踪连接的状态。现阶段,我们将使用 connecteddisconnected这两个成员,分别用于监测新连接建立以及连接断开的事件。如需查看全部可用事件,请查阅 API 文档。

注意

这些事件和上一小节介绍的连接事件有所不同,上一小节的那些连接事件对应用程序是不可见的。这里的事件称为连接回调事件。

我们将把回调函数命名为 on_connected 和 on_disconnected。

在 main.c 中添加以下代码:

struct bt_conn_cb connection_callbacks = {
    .connected              = on_connected,
    .disconnected           = on_disconnected,
    .recycled               = on_recycled,
};

2.2 定义回调函数 on_connected 和 on_disconnected

由于回调函数是由蓝牙库触发调用的,因此这些函数的形参必须与上面链接的 API 文档中所描述的保持一致,这一点十分重要。

我们先编写一些简易的回调函数,后续可以在此基础上继续完善。请在 main.c 文件中添加下面这些函数:

void on_connected(struct bt_conn *conn, uint8_t err)
{
    if (err) {
        LOG_ERR("Connection error %d", err);
        return;
    }
    LOG_INF("Connected");
    my_conn = bt_conn_ref(conn);

    /* STEP 3.2  Turn the connection status LED on */
}

void on_disconnected(struct bt_conn *conn, uint8_t reason)
{
    LOG_INF("Disconnected. Reason %d", reason);
    bt_conn_unref(my_conn);

    /* STEP 3.3  Turn the connection status LED off */
}

void on_recycled(void)
{
    advertising_start();
}

my_conn 参数只是一个 bt_conn 指针,该指针已经在 main.c 文件靠上方的位置完成声明。在下一个练习中,我们会使用它来维护连接状态。

2.3 注册回调结构体 connection_callbacks

调用 bt_conn_cb_register函数完成回调结构体的注册。该函数必须在开启广播之前调用,防止在回调尚未注册完成时就已经建立连接。

在 main.c 中添加下面这行代码:

err = bt_conn_cb_register(&connection_callbacks);
	if (err) {
		LOG_ERR("Connection callback register failed (err %d)", err);
		return -1;
	}

注意

请注意,在很多低功耗蓝牙示例代码中,你不会看到 bt_conn_cb_register () 的调用。取而代之的是使用宏 BT_CONN_CB_DEFINE (),该宏可以同时完成回调的定义与注册。

2.4 on_recycled () 回调需要做一些额外说明。on_connected () 和 on_disconnected 回调的含义通俗易懂,但 on_recycled () 回调就不太好理解了。你可以在 conn.h 文件中查看它的声明(右键点击 .recycled,选择 “转到声明”)。每当连接对象归还回对象池时,就会调用该回调。这通常代表已经发生断开连接,并且其中一个连接已经解除引用,意味着此时你可以重新开启广播。

注意

你可能会发现,开启广播这部分逻辑增加了一些复杂度。你会看到名为 “k_work” 以及 “工作处理函数(work handlers)” 的内容。之所以使用它们,是因为在蓝牙回调函数内部调用蓝牙 API 时必须十分谨慎。如果不遵循相关使用规范,有可能会触发断言,进而导致应用程序重启。

工作处理函数的作用是把待执行的函数加入队列;该函数会在中断之外运行,也就是等应用处理完高优先级中断之后再执行。在本场景下,我们不能直接在 .recycled 回调内部调用 bt_le_adv_start ()。取而代之,我们调用 advertising_start (),由它将对 bt_le_adv_start () 的调用加入工作队列。

3. 配置一个 LED 用于指示当前的连接状态

3.1 定义连接状态 LED

在 main.c 文件靠近顶部的位置添加下面这行代码:

#define CONNECTION_STATUS_LED   DK_LED2

3.2 在连接事件中将连接状态 LED 点亮

在连接事件的回调函数 on_connected () 中,添加下面这行代码以点亮 LED。

dk_set_led(CONNECTION_STATUS_LED, 1);

3.3 在断开连接事件中将连接状态 LED 熄灭

在断开连接事件的回调函数 on_disconnected () 中,添加下面这行代码以熄灭 LED。

dk_set_led(CONNECTION_STATUS_LED, 0);

4. 编译并将应用程序烧录至开发板。

5. 打开终端查看应用程序的日志输出。

和我们在第 1 课中的操作一样,在 VS Code 中展开 “已连接设备(Connected Devices)” 下对应的开发板,选择该设备的 COM 端口进行连接。你的电脑上显示的 COM 端口号可能有所不同。

注意

大多数较新款的开发板会提供不止一个 COM 端口。两个端口都尝试一下,确认哪一个属于应用内核。端口号较小的那一个通常是,但并非一定就是应用内核对应的端口。

使用默认串口参数:115200、8N1、关闭 rtscrs。然后复位设备,查看完整日志信息。

重启设备以查看完整日志输出。此时,你应当可以看到如下日志输出。

*** Booting nRF Connect SDK ***
*** Using Zephyr OS ***
[00:00:00.004,302] <inf> Lesson3_Exercise1: Starting Lesson 3 - Exercise 1
[00:00:00.007,659] <inf> Lesson3_Exercise1: Bluetooth initialized
[00:00:00.008,605] <inf> Lesson3_Exercise1: Advertising successfully started

6. 使用手机扫描并连接开发板

和前面的练习一样,打开 nRF Connect for Mobile 应用扫描设备。找到名称为 “Nordic_Peripheral” 的设备,点击名称旁边的 Connect(连接)进行连接。此时软件会打开一个带有多个选项卡的新窗口,不过现阶段我们重点关注开发板上发生的现象。

留意开发板上的 LED,此时该 LED 会指示连接状态;同时你应该可以看到如下日志输出。

[00:00:26.831,720] <inf> Lesson3_Exercise1: Connected

这表明我们现已处于已连接状态。在此连接当中,手机充当中心设备,接收来自 nRF 设备(外设)发出的广播数据包。

重要提示

如果你使用的是 iOS 手机,在连接事件之后,日志中还会出现下面几行警告信息,该警告可以直接忽略。

7. 在手机端断开设备连接

点击右上角的 “Disconnect(断开连接)” 按钮,断开与该外设的连接。

你应该会看到如下日志输出。

[00:00:38.627,004] <inf> Lesson3_Exercise1: Disconnected. Reason 19

错误码在蓝牙规范中进行了定义,可以用于指示连接终止的原因。

如果你打开 hci_types.h 文件,可以看到十进制数值 19(十六进制为 0x13)对应的是 BT_HCI_ERR_REMOTE_USER_TERM_CONN,含义是远端用户主动断开了连接。如果我们重新做一次测试,不在 APP 里面执行断开操作,而是把手机拿到远离开发板的位置,就会看到断开原因的值为 8。该值对应 BT_HCI_ERR_CONN_TIMEOUT,代表发生了监管超时。

到这一步,我们希望能够在外设与中心设备之间传输一些数据。要实现该功能,需要在应用程序中添加一项服务。我们将向应用程序添加 LED 按键服务(LED Button Service)。

补充说明

我们将在第 4 课更加详细地讲解低功耗蓝牙服务以及数据发送。现阶段只需了解:该函数用于向已连接的设备发送数据即可。

8. 修改应用程序,使得按下按键 1 时发送一条消息

我们希望每次按下按键 1 就发送一些数据;要实现该功能,需要添加 LED 按键服务(LBS)。

8.1 在应用程序中启用 LBS 服务

首先,请在 prj.conf 文件中添加下面这行配置。

CONFIG_BT_LBS=y

8.2 在 main.c 文件靠近顶部的位置引入 LED 按键服务头文件。

#include <bluetooth/services/lbs.h>

8.3 添加回调函数,用于检测按键 1 的按下事件

每当开发板上的按键 1 按下时,我们希望把按键 1 的状态发送给中心设备(你的手机)。

在 main.c 文件中添加 button_changed () 回调函数:

static void button_changed(uint32_t button_state, uint32_t has_changed)
{
	int err;
	bool user_button_changed = (has_changed & USER_BUTTON) ? true : false;
	bool user_button_pressed = (button_state & USER_BUTTON) ? true : false;
	if (user_button_changed) {
		LOG_INF("Button %s", (user_button_pressed ? "pressed" : "released"));

		err = bt_lbs_send_button_state(user_button_pressed);
		if (err) {
			LOG_ERR("Couldn't send notification. (err: %d)", err);
		}
	}
}

8.4 完成 init_button () 函数的实现

在 init_button () 函数中,我们将注册 button_changed () 函数,使得开发板上的按键按下时可以调用该函数。

    err = dk_buttons_init(button_changed);
    if (err) {
        LOG_ERR("Cannot init buttons (err: %d)", err);
    }

9. 编译并将应用程序烧录到开发板,然后使用手机连接该设备。

建立连接之后,点击名为 “Cli…” 的选项卡(如果屏幕尺寸足够大,则会显示为 Client)。该选项卡列出了已连接设备上的全部服务。找到 “Nordic LED and Button Service” 并点击展开。

点击由多个向下箭头组成的图标,开启该特征值的通知功能。

此时按下开发板上的按键 1(nRF54 系列设备上为按键 0),你可以看到 Value(数值)字段会在 “Button Released(按键松开)” 和 “Button Pressed(按键按下)” 之间切换。

请记住:在按下开发板按键之前,远端设备(你的手机或者平板)必须先订阅通知。如果远端设备尚未订阅通知就按下按键 1,终端上会打印错误信息:Lesson3_Exercise1: Couldn't send notification. err: -13。下一课我们会进一步讲解通知相关内容。

练习 2 — 更新连接参数

在上一个练习中,我们以 Nordic 开发板作为外设、手机作为中心设备建立了连接。借助已经预先实现好的 LED 按键服务,我们成功发送了一些简单的数据。虽然我们并没有直观看到这一过程,但中心设备在连接外设的时候就已经选定了一组连接参数。在本练习中,我们将查看这些参数具体是什么,同时学习如何修改连接参数。

我们将在第 4 课更加详细地讲解低功耗蓝牙服务以及数据发送。现阶段只需了解:我们正在向已连接的设备发送部分数据即可。

练习步骤

本课程的 GitHub 代码库中,打开本练习的基础代码,路径为 l3/l3_e2。

1. 获取当前连接的连接参数

1.1 声明结构体用于保存连接参数

在 on_connected () 回调函数内部,声明一个 bt_conn_info 类型的结构体变量 info,用来存放连接参数。然后调用 bt_conn_get_info ()函数,将本次连接所使用的连接参数填充到 info 结构体中。

struct bt_conn_info info;
	err = bt_conn_get_info(conn, &info);
	if (err) {
		LOG_ERR("bt_conn_get_info() returned %d", err);
		return;
	}

1.2 将连接参数输出到日志

接下来输出我们在 “连接参数” 部分讲到的三项主要连接参数。请注意:连接间隔的单位为 1.25 ms,监管超时的单位为 10 ms。因此我们需要做一些换算,让日志更便于阅读。

在 main.c 文件的 on_connected () 回调函数末尾添加以下代码:

double connection_interval = BT_GAP_US_TO_CONN_INTERVAL(info.le.interval_us) *1.25; // in ms
uint16_t supervision_timeout = info.le.timeout*10; // in ms
LOG_INF("Connection parameters: interval %.2f ms, latency %d intervals, timeout %d ms", connection_interval, info.le.latency, supervision_timeout);

注意

为了让日志能够处理浮点(double)类型的数据,我们已经在 prj.conf 文件中添加了配置项 CONFIG_FPU=y。如果你使用的是 GitHub 上的模板代码,则该项配置已经预先添加好了。

2. 将示例程序编译并烧录至开发板

你的日志输出应当类似如下内容:

*** Booting nRF Connect SDK ***
[00:00:00.251,098] <inf> Lesson3_Exercise2: Starting Lesson 3 - Exercise 2
[00:00:00.008,453] <inf> Lesson3_Exercise2: Bluetooth initialized
[00:00:00.009,490] <inf> Lesson3_Exercise2: Advertising successfully started

2. 使用手机连接该设备

打开 nRF Connect for Mobile,搜索名称为 “Nordic_Peripheral” 的设备并建立连接。

日志输出将显示本次连接所使用的连接参数。

[00:00:03.989,349] <inf> Lesson3_Exercise2: Connected
[00:00:03.989,379] <inf> Lesson3_Exercise2: Connection parameters: interval 30.00 ms, latency 0 intervals, timeout 240 ms

请注意,你实际得到的连接参数可能与此处展示的有所不同。

4. 修改回调函数,以便在低功耗蓝牙连接的参数完成更新时获得通知。

你可能已经注意到,在上一个练习中我们所定义的 bt_conn_cb结构体还包含 le_param_updated 成员。该成员用于通知应用程序:低功耗蓝牙(LE)连接的连接参数已经完成更新。

注意

另外还有一个回调成员 le_param_req,当已连接的设备请求更新连接参数时会触发该回调。实际开发时你的应用程序中很可能需要用到它,但本练习不分析该事件。

4.1 修改 connection_callbacks 回调结构体,添加下面这行代码:

.le_param_updated       = on_le_param_updated,

4.2 添加 on_le_param_updated () 事件回调

定义回调函数 on_le_param_updated (),用于输出更新后的连接参数日志。

在 main.c 文件中添加下面的函数:

void on_le_param_updated(struct bt_conn *conn, uint16_t interval, uint16_t latency, uint16_t timeout)
{
    double connection_interval = interval*1.25;         // in ms
    uint16_t supervision_timeout = timeout*10;          // in ms
    LOG_INF("Connection parameters updated: interval %.2f ms, latency %d intervals, timeout %d ms", connection_interval, latency, supervision_timeout);
}

4.3 到目前为止,如果我们烧录应用程序并连接设备,可以看到几秒钟之后该回调就会被触发,你可能会得到类似下方的日志输出。请注意,该行为会因你的手机型号不同而有所差异。

[00:00:00.008,483] <inf> Lesson3_Exercise2: Bluetooth initialized
[00:00:00.009,521] <inf> Lesson3_Exercise2: Advertising successfully started
[00:00:21.988,372] <inf> Lesson3_Exercise2: Connected
[00:00:21.988,403] <inf> Lesson3_Exercise2: Connection parameters: interval 30.00 ms, latency 0 intervals, timeout 240 ms

[00:00:27.239,562] <inf> Lesson3_Exercise2: Connection parameters updated: interval 45.00 ms, latency 0 intervals, timeout 420 ms

也就是说,其实一直以来连接参数变更请求都是由你的设备发起的。这是因为,虽然我们并没有主动发起参数更新请求,但在 nRF Connect SDK启用蓝牙协议栈时,很多参数都已经设置了默认值,其中就包括外设的首选连接参数。如果这些参数和中心设备在初次连接时所使用的参数不一致,外设就会自动发起连接参数变更请求,尝试切换至自身的首选参数。

我们来看一下它们的默认值:

连接间隔:

外设从机延迟:

监管超时:

以上这些配置项全部位于 Kconfig.gatt 文件中,文件路径为:<install_path>\zephyr\subsys\bluetooth\host。

因此我们可以从上面的日志输出看到,初始连接的监管超时是 240 ms。于是外设发起了参数变更请求;完成这次更新之后,所有连接参数就都符合我们的首选设置了。

5. 修改首选连接参数

我们将在 prj.conf 文件中添加下面几行配置,以此修改外设的首选连接参数。

CONFIG_BT_PERIPHERAL_PREF_MIN_INT=800
CONFIG_BT_PERIPHERAL_PREF_MAX_INT=800
CONFIG_BT_PERIPHERAL_PREF_LATENCY=0
CONFIG_BT_PERIPHERAL_PREF_TIMEOUT=400
CONFIG_BT_GAP_AUTO_UPDATE_CONN_PARAMS=y

最后一项配置 CONFIG_BT_GAP_AUTO_UPDATE_CONN_PARAMS,它负责自动发送连接参数更新请求;该项无需额外设置,因为其默认值已经是 y。这也就是为什么即便我们没有手动发起请求,依然可以观察到参数更新行为。

如果你不希望应用程序自动发起更新请求,可以关闭这个 Kconfig 配置。此种情况下,你可以在应用代码里通过调用 bt_conn_le_param_update () 函数手动发起参数变更请求。

上面的配置会将首选连接间隔的最小值与最大值均设置为 1 秒;首选外设从机延迟设置为 0 个连接间隔;首选监管超时设置为 4 秒。

6. 编译并烧录应用程序,然后使用手机连接该设备。

日志将会输出类似如下内容。请注意:在连接事件发生之后,大约需要等待 5 秒,连接参数才会完成更新。

[00:00:00.008,453] <inf> Lesson3_Exercise2: Bluetooth initialized
[00:00:00.009,490] <inf> Lesson3_Exercise2: Advertising successfully started
[00:06:35.863,891] <inf> Lesson3_Exercise2: Connected
[00:06:35.863,922] <inf> Lesson3_Exercise2: Connection parameters: interval 30.00 ms, latency 0 intervals, timeout 240 ms
[00:06:41.113,983] <inf> Lesson3_Exercise2: Connection parameters updated: interval 1005.00 ms, latency 0 intervals, timeout 4000 ms

由此可以看到,手机初始建立连接时,连接间隔为 30 ms,监管超时为 240 ms。在我们发起变更请求之后,连接间隔修改为 1.005 s,监管超时修改为 4 s。尽管 1005 ms 超出了我们设置的首选最小、最大间隔范围,但如何响应请求是由中心设备决定的。这些数值会随连接中的中心设备(也就是你的智能手机)而变化,但你很有可能会观察到 1000 ms。作为中心设备的手机拥有最终决定权,由它来设定实际的连接参数。

注意

如果想要实际查看连接间隔,可以使用移动端应用 nRF Blinky。当按下开发板上的按键时,该应用能够显示连接间隔的变化。

受 nRF Connect for Mobile 应用图形界面刷新率的限制,使用这款应用时你将看不到连接间隔的变化。

现在我们已经让应用程序的响应速度变慢了。大家可能会觉得,一个运行更慢的应用怎么反而更好呢?而且在很多场景下,慢确实并不好。但请记住:如果想要降低通信延迟,设备就需要更加频繁地收发数据,功耗也会随之上升。开发你自己的低功耗蓝牙应用时,这一点需要加以考量。举例来说,温度传感器并不需要每秒更新 10 次数据。可以尝试调整这些连接参数,找到适合你应用的配置。

7. 设置首选 PHY

首先,我们需要配置一组首选 PHY,这里我们将使用 2M PHY。

7.1 定义 update_phy () 函数以更新连接的 PHY

在 main.c 文件中创建如下函数:

static void update_phy(struct bt_conn *conn)
{
    int err;
    const struct bt_conn_le_phy_param preferred_phy = {
        .options = BT_CONN_LE_PHY_OPT_NONE,
        .pref_rx_phy = BT_GAP_LE_PHY_2M,
        .pref_tx_phy = BT_GAP_LE_PHY_2M,
    };
    err = bt_conn_le_phy_update(conn, &preferred_phy);
    if (err) {
        LOG_ERR("bt_conn_le_phy_update() returned %d", err);
    }
}

我们希望直接在连接回调事件中触发 PHY 更新。设置首选 PHY 参数,指定优先使用 BT_GAP_LE_PHY_2M。

7.2 在连接建立过程中调用 PHY 更新函数

在 on_connected () 回调函数的末尾调用 update_phy () 函数,传入 my_conn 作为输入参数。添加下面这行代码:

update_phy(my_conn);

8. 添加回调函数,以便在连接的 PHY 发生变更时收到通知

理论上上述操作就可以生效,但我们需要一种方式来确认 PHY 是否确实发生了切换。因此,我们要在 connection_callbacks 中添加 le_phy_updated回调;当连接的 PHY 发生变化时,该回调就会通知我们。

8.1 实现 on_le_phy_updated () 回调函数。参考实现如下:

void on_le_phy_updated(struct bt_conn *conn, struct bt_conn_le_phy_info *param)
{
    // PHY Updated
    if (param->tx_phy == BT_CONN_LE_TX_POWER_PHY_1M) {
        LOG_INF("PHY updated. New PHY: 1M");
    }
    else if (param->tx_phy == BT_CONN_LE_TX_POWER_PHY_2M) {
        LOG_INF("PHY updated. New PHY: 2M");
    }
    else if (param->tx_phy == BT_CONN_LE_TX_POWER_PHY_CODED_S8) {
        LOG_INF("PHY updated. New PHY: Long Range");
    }
}

8.2 开启 PHY 更新功能

回调函数已经编写完毕,但目前我们还不能把它添加到 connection_callbacks 结构体中。如果你查看 conn.h 文件中 struct bt_conn_cb的声明就可以看到,只有在定义了 CONFIG_BT_USER_PHY_UPDATE 配置项时,该回调才会被定义;而默认情况下该配置项并未启用。

将下面这一行添加到 prj.conf 文件中:

CONFIG_BT_USER_PHY_UPDATE=y

8.3 将 le_phy_updated 事件添加至 connection_callbacks 参数,在 connection_callbacks 结构体中加入下面这行代码:

.le_phy_updated         = on_le_phy_updated,

9. 编译并烧录应用程序

接下来尝试连接你的设备,查看 PHY 是否完成更新。日志输出是什么内容?输出应该和下面类似:

[00:00:00.008,422] <inf> Lesson3_Exercise2: Bluetooth initialized
[00:00:00.009,460] <inf> Lesson3_Exercise2: Advertising successfully started
[00:00:16.133,392] <inf> Lesson3_Exercise2: Connected
[00:00:16.133,422] <inf> Lesson3_Exercise2: Connection parameters: interval 30.00 ms, latency 0 intervals, timeout 240 ms
[00:00:16.224,334] <inf> Lesson3_Exercise2: PHY updated. New PHY: 2M
[00:00:21.189,422] <inf> Lesson3_Exercise2: Connection parameters updated: interval 1005.00 ms, latency 0 intervals, timeout 4000 ms

补充说明

我们选择 2M 作为首选 PHY 并非偶然。大多数新款手机都支持 2M PHY,但只有部分手机支持编码 PHY(Coded PHY)。如果你想确认自己的手机是否支持编码 PHY,可以将 preferred_phy 里的 BT_GAP_LE_PHY_2M 替换为 BT_GAP_LE_PHY_CODED。除此之外,还需要启用 Kconfig 配置项 CONFIG_BT_CTLR_PHY_CODED。

最后,我们希望增大数据长度以及 MTU 的大小。尽管我们当前所使用的 LBS 服务仅支持发送 1 字节的有效载荷数据,但该项设置在很多应用当中都十分有用。

10. 定义 update_data_length () 函数用于更新数据长度

将该函数添加到你的 main.c 文件中:

static void update_data_length(struct bt_conn *conn)
{
    int err;
    struct bt_conn_le_data_len_param my_data_len = {
        .tx_max_len = BT_GAP_DATA_LEN_MAX,
        .tx_max_time = BT_GAP_DATA_TIME_MAX,
    };
    err = bt_conn_le_data_len_update(my_conn, &my_data_len);
    if (err) {
        LOG_ERR("data_len_update failed (err %d)", err);
    }
}

这里我们将字节数与时间参数设置为最大值。此处使用了宏定义值 BT_GAP_DATA_LEN_MAX 和 BT_GAP_DATA_TIME_MAX,二者分别为 251 字节和 17040 微秒。如果你需要,也可以自行设置自定义参数。

请注意,该操作所触发的协商,最终取值为连接两端设备均能够支持的最大参数,因此你申请的 251 字节未必能够完全生效。最终将由支持较短数据长度的那一端设备来决定实际结果。

11.1 定义 update_data_mtu () 函数以触发 MTU 协商

在 main.c 文件中添加如下函数:

static void update_mtu(struct bt_conn *conn)
{
    int err;
    exchange_params.func = exchange_func;

    err = bt_gatt_exchange_mtu(conn, &exchange_params);
    if (err) {
        LOG_ERR("bt_gatt_exchange_mtu failed (err %d)", err);
    }
}

与之前请求更新数据长度的方式类似,我们让设备发起一次 MTU 更新请求。在 MTU 更新协商过程中,两台设备都会声明各自所支持的 MTU 大小;实际生效的 MTU 将取二者之中较小的值,该值即为限制因素。你可以注意到该函数内部并没有填写实际的 MTU 数值。这是因为 MTU 需要在 prj.conf 文件中进行配置,我们很快就会完成该项设置。

11.2 我们还需要声明 exchange_params 参数。该参数必须定义在 update_mtu () 函数外部,因此我们将它放置在 main.c 文件靠近顶部的位置。

static struct bt_gatt_exchange_params exchange_params;

12. 配置应用程序以启用数据长度扩展

将以下内容添加到你的 prj.conf 文件中

通用:

CONFIG_BT_USER_DATA_LEN_UPDATE=y
CONFIG_BT_CTLR_DATA_LENGTH_MAX=251
CONFIG_BT_BUF_ACL_RX_SIZE=251
CONFIG_BT_BUF_ACL_TX_SIZE=251
CONFIG_BT_L2CAP_TX_MTU=247

首先,我们通过配置 CONFIG_BT_USER_DATA_LEN_UPDATE=y 开启数据长度扩展功能。接着设置 CONFIG_BT_CTLR_DATA_LENGTH_MAX=251,指定实际的数据长度。然后对将要使用的实际缓冲区大小进行配置,最后设置应用程序所需要使用的 MTU 大小。

nRF5340 DK:

CONFIG_BT_USER_DATA_LEN_UPDATE=y
CONFIG_BT_BUF_ACL_RX_SIZE=251
CONFIG_BT_BUF_ACL_TX_SIZE=251
CONFIG_BT_L2CAP_TX_MTU=247

首先我们通过设置 CONFIG_BT_USER_DATA_LEN_UPDATE=y 来启用数据长度扩展。接着配置实际所用缓冲区的大小,最后设置应用程序需要使用的 MTU 大小。

除此之外,我们还需要对网络核添加部分配置,对应的配置文件路径为 child_image/hci_rpmsg.conf/prj.conf。

重要说明

如果你使用的是 nRF Connect SDK v2.8.0 及以上版本:

网络核的相关配置在 sysbuild/ipc_radio/prj.conf 文件中完成

如果你使用的 nRF Connect SDK 版本介于 v2.6.0 ~ v2.7.99:

网络核的相关配置在 child_image/hci_ipc.conf 文件中完成

对于更早的 SDK 版本:

网络核的相关配置在 child_image/hci_rpmsg.conf 文件中完成

根据你所使用的 nRF Connect SDK 版本,将以下配置添加到 hci_rpmsg.conf、hci_ipc.conf 或者 sysbuild/ipc_radio/prj.conf 文件中。

CONFIG_BT_CTLR_DATA_LENGTH_MAX=251
CONFIG_BT_BUF_ACL_RX_SIZE=251
CONFIG_BT_BUF_ACL_TX_SIZE=251
CONFIG_BT_MAX_CONN=2

此处除了设置缓冲区大小之外,我们还要设置需要使用的数据长度,因为正是该内核实际负责维护缓冲区。网络核默认将最大连接数设置为 16;如果再增大数据长度,会导致应用程序很快耗尽 RAM 内存。因此我们需要设置 CONFIG_BT_MAX_CONN=2。

请注意,本练习中该文件已经预先创建好了。但如果你的工程里没有该文件,可以自行新建,它将会被自动纳入网络核的编译构建过程。

13. 实现两个回调函数,分别在数据长度更新以及 MTU 更新时触发

13.1 首先我们添加数据长度更新回调函数

void on_le_data_len_updated(struct bt_conn *conn, struct bt_conn_le_data_len_info *info)
{
    uint16_t tx_len     = info->tx_max_len;
    uint16_t tx_time    = info->tx_max_time;
    uint16_t rx_len     = info->rx_max_len;
    uint16_t rx_time    = info->rx_max_time;
    LOG_INF("Data length updated. Length %d/%d bytes, time %d/%d us", tx_len, rx_len, tx_time, rx_time);
}

13.2 我们还需要将其添加到 connection_callbacks 当中:

.le_data_len_updated    = on_le_data_len_updated,

13.3 接下来我们需要实现在 update_mtu () 函数中所设置的回调函数:

static void exchange_func(struct bt_conn *conn, uint8_t att_err,
			  struct bt_gatt_exchange_params *params)
{
	LOG_INF("MTU exchange %s", att_err == 0 ? "successful" : "failed");
    if (!att_err) {
        uint16_t payload_mtu = bt_gatt_get_mtu(conn) - 3;   // 3 bytes used for Attribute headers.
        LOG_INF("New MTU: %d bytes", payload_mtu);
    }
}

13.4 对 exchange_func () 进行前向声明。

由于 MTU 变更回调函数 exchange_func () 在 main.c 文件中写在 update_mtu () 函数实现的后面,我们需要在 update_mtu () 函数实现之前添加该函数的声明。

在 main.c 文件中添加下面这行代码:

static void exchange_func(struct bt_conn *conn, uint8_t att_err, struct bt_gatt_exchange_params *params);

13.5 在 on_connected () 中调用用于更新数据长度与 MTU 大小的函数。

请务必从 on_connected () 回调函数内调用我们刚刚实现的全部参数交互函数。

k_sleep(K_MSEC(1000));  // Delay added to avoid link layer collisions.
update_data_length(my_conn);
update_mtu(my_conn);

增加 1 秒延时是为了避免链路层发生冲突。我们不必深究细节,根据低功耗蓝牙规范,不允许同时存在两个此类请求处于激活状态。

14. 编译并将应用程序烧录到你的设备上,然后使用手机连接该设备。

你的日志输出内容应当与下面类似:

[00:00:00.008,056] <inf> Lesson3_Exercise2: Bluetooth initialized
[00:00:00.009,094] <inf> Lesson3_Exercise2: Advertising successfully started
[00:00:22.159,820] <inf> Lesson3_Exercise2: Connected
[00:00:22.159,820] <inf> Lesson3_Exercise2: Connection parameters: interval 30.00 ms, latency 0 intervals, timeout 240 ms
[00:00:22.508,392] <inf> Lesson3_Exercise2: Data length updated. Length 251/251, time 2120/2120
[00:00:22.627,807] <inf> Lesson3_Exercise2: PHY updated. New PHY: 2M
[00:00:22.658,050] <inf> Lesson3_Exercise2: MTU exchange successful
[00:00:22.658,050] <inf> Lesson3_Exercise2: New MTU: 244 bytes

[00:00:27.428,161] <inf> Lesson3_Exercise2: Connection parameters updated: interval 1005.00 ms, latency 0 intervals, timeout 4000 ms

你将会看到来自各个不同回调函数的大量日志,输出本次连接所有的各项连接参数。

补充说明

nRF5340 DK:请注意,如果你查看本练习的参考示例,你可能是将所有配置都放置到了 prj.conf 文件中。在参考工程的 l3/l3_e2/boards 目录下,你可以看到 nrf5340dk_nrf5340_cpuapp.conf 和 nrf5340dk_nrf5340_cpuapp_ns.conf 这两个文件。如果你使用 nRF5340 DK,根据你正在编译的目标版本不同,系统会选用其中一个文件作为配置文件。

测验

请确保在参加测验前已学完所有小节内容。测验是完成本课学习的最后一步。如果未按既定顺序完成本课内容,请务必重新作答测验,才能将本课标记为已完成。

点击开始测验 (英文)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

短距离

适用于短距离物联网的蓝牙低功耗及多协议系统级芯片(支持Thread、Matter、Zigbee协议)

长距离

适用于LTE-M/NB-IoT、GNSS、DECT NR+和NTN的蜂窝物联网系统级封装

Wi-Fi

低功耗Wi-Fi 6协同ICs,支持2.4 GHz/5 GHz频段选项及WPA3加密协议

电源管理ICs

适用于电池供电设备的电源管理IC(PMIC),nPM系列提供充电与稳压功能选项。

AI及软件工具

工具与NPU加速边缘人工智能开发和部署

订阅Nordic新闻简报

了解最新信息!订阅后即可获取最新Nordic及物联网资讯

立即订阅