手把手教你开发Bluetooth LE数据透传应用程序 2024-05-06

Nordic开发环境如何快速搭建?如何理解Nordic的Bluetooth LE透传服务?如何开发自己的数据透传应用?如何提高Bluetooth LE数据传输速率?手机和Bluetooth LE设备之间通信有没有什么工具可以进行压力测试?哪里可以找到手机端Bluetooth LE app参考程序?本文将对以上问题进行解答。

Nordic开发环境如何快速搭建?如何理解Nordic的Bluetooth LE透传服务?如何开发自己的数据透传应用?如何提高Bluetooth LE数据传输速率?手机和Bluetooth LE设备之间通信有没有什么工具可以进行压力测试?哪里可以找到手机端Bluetooth LE app参考程序?本文将对以上问题进行解答。

在很多应用场合,Bluetooth LE只是作为一个数据透传模块,即将设备端数据上传给手机,同时接收手机端下发的数据。本文将和大家一起,一步一步演示如何开发一个Bluetooth LE透传应用程序。按照本文的说明,大家可以很快就实现一个Bluetooth LE透传应用,Bluetooth LE透传应用已经是Bluetooth LE应用中比较复杂的一种,一旦大家掌握了Bluetooth LE透传应用,其他Bluetooth LE应用开发就更不在话下了。本文还将手把手教大家如何提高Bluetooth LE数据传输速度(在蓝牙4.2模式下我们实测速度达到了85kB/s(理论值90kB/s);在蓝牙5.0模式下我们实测速度达到了150kB/s(理论值170kB/s))。最后,我们将告诉大家如何使用安卓版nRF Connect来对你的Bluetooth LE设备进行压力测试,以测试设备的稳定性和可靠性。文章的最后还会告诉大家如何找到安卓和iOS手机Bluetooth LE app开发参考代码。

这是一篇纯实践的文章,如果你对低功耗蓝牙的一些基本概念还不是很懂的话,那么建议你先看一下这篇文章:低功耗蓝牙ATT_GATT_Profile_Service_Char规格解读,有了Bluetooth LE这些基本概念后,再去看下面的内容,就容易多了。

文中涉及的所有代码都可以在百度网盘找到,地址如下所示:

  • 链接: https://pan.baidu.com/s/1FKTfY3Q_zBVvviO7KC7Gyg#list/path=%2Fblog 密码: y8fb

下载“ble_app_uart_hs_SDK16_0_0.rar”,解压缩到SDK16.0.0如下目录下:nRF5SDK160098a08e2\examples\ble_peripheral,即可成功编译和运行。

注意:虽然本文代码是基于SDK16.0.0开发的,但所有新增代码都可以直接拷贝到SDK15.0.0/SDK15.2.0/SDK15.3.0的ble_app_uart例子,并可以成功编译和运行,他们是完全兼容的。

1. 开发准备

  1. Nordic nRF52或者nRF51开发板1块。请参考“Nordic nRF51/nRF52开发流程说明”,购买相应开发板(DK)。
  2. 开发环境搭建。简述如下(详细说明请参考“Nordic nRF51/nRF52开发环境搭建”):

注:如果你使用的是Linux系统/Mac系统,或者你使用的不是Keil5-MDK,请参考“Nordic nRF51/nRF52开发环境搭建”来搭建你的开发环境。

2. 运行Nordic ble_app_uart应用程序

Nordic SDK已经提供了一个直接就可以编译和运行的数据透传应用程序:ble_app_uart,Nordic将Bluetooth LE透传服务称为Nordic UART Service(NUS),所以在Nordic SDK中,NUS就是Bluetooth LE透传服务。请按照如下步骤运行SDK自带的ble_app_uart程序:

  • 确认自己的芯片型号或者开发板。如果采用Nordic官方开发板的话,芯片型号和开发板编号对应关系如下:
    • nRF52832和nRF52810对应开发板编号为PCA10040。虽然52832和52810共用同一块开发板,但是他们在SDK中的项目编号是不一样的,52832对应PCA10040目录,52810对应PCA10040e目录,由于52810和52832 PIN to PIN兼容,软件也是完全兼容的,因此SDK很多项目只有PCA10040的目录,而没有PCA10040e目录,此时需要你自己来建立PCA10040e对应的目录和工程,具体说明可参考:https://docs.nordicsemi.com/bundle/sdk_nrf5_v17.1.0/page/nrf52810_user_guide.html
    • nRF52840和nRF52811对应开发板编号为PCA10056。与832/810相似,52840对应的工程目录为PCA10056,而52811对应的工程目录为PCA10056e。如何建立一个PCA10056e的项目工程可以参考:https://docs.nordicsemi.com/bundle/sdk_nrf5_v17.1.0/page/nrf52811_user_guide.html
    • nRF52840 dongle编号为PCA10059。PCA10059是一个可以直接插入电脑的小dongle,它也是使用52840芯片,但本身不带JLink接口芯片。
    • nRF52833对应开发板编号为PCA10100
    • nRF51系列对应开发板编号为PCA10028

这里我会以nRF52832开发板PCA10040为例来阐述整个开发过程,其他开发板与之类似,大家可以举一反三来开始自己的开发之旅。

  • 将开发板与PC机通过USB线相连,同时打开开发板电源(将左下角的拨位开关打到“ON”位置)。

ble app

  • 打开SDK中的ble_app_uart程序。如果是52832开发板,请打开:nRF5_SDK_15.0.0_a53641a\examples\ble_peripheral\ble_app_uart\pca10040\s132\arm5_no_packs;如果是51822开发板,请打开:nRF5_SDK_12.3.0_d7731ad\examples\ble_peripheral\ble_app_uart\pca10028\s130\arm5_no_packs

    后续将以52832开发板为例来阐述,51822等其他芯片与之类似就不再阐述了。

    注:Nordic SDK例程目录结构为:SDK版本/ examples /蓝牙角色/例子名称/开发板型号/协议栈型号/工具链类型/具体工程,比如下面例子:

ble app

Nordic每一个例子都支持5种工具链:Keil5/Keil4/IAR/GCC/SES,如下所示:

ble app

  • 编译程序。如果你已经按照之前的说明配置好了开发环境,那么这里编译是不会报任何错的。(如果你遇到了编译错误,请重新按照前面说明去搭建你的开发环境,不要怀疑SDK例子代码有问题哦)
  • 下载程序。程序下载包括2步:一先下载softdevice,二再下载应用。Softdevice是Nordic蓝牙协议栈的名称,整个开发过程中只需下载一次。应用就是我们这里的ble_app_uart程序。如果你的开发板已经下载了其他代码,那么最好先把开发板全擦一次,然后再下载softdevice和应用。
    • 芯片全擦(可选)。你可以使用nRF connect桌面版或者nrfjprog,二选其一来执行擦除操作。

      • 打开桌面版nRF Connect,选择启动“Programmer”应用,由于驱动之前已经安装好了,设备可以立即识别成功,执行“Erase all”操作,以擦除芯片原始内容。
      • 使用nrfjprog执行全擦操作。
    • 蓝牙协议栈下载(整个开发周期只需下载一次)。在Keil ‘select target’下拉列表中,默认选择的是Keil工程对应的Target,即‘nrf52832_xxaa’。我们还可以选择另一个target ‘flash_s132_nrf52_7.0.1_softdevice’,即softdevice对应的target,然后点击“下载download”(不需要编译哦!),此时会把softdevice下载到开发板中。

      注:你也可以通过桌面版nRF connect的programmer或者nrfjprog来下载softdevice,下载的时候,找到相应的softdevice hex文件,然后直接下载进去即可。

    • 应用下载。重新选择Target:‘nrf52832_xxaa’,点击“下载Download”,此时会把ble_app_uart应用程序下载到开发板中。此时开发板的LED1闪烁,表示程序运行正常。

  • 连接手机。打开手机蓝牙和手机版nRF connect。在nRF connect中,你将看到一个广播设备:Nordic_UART,这个就是开发板的广播名字。点击“CONNECT”,手机将与设备建立连接,并开始服务发现过程,连接成功后,LED1熄灭,LED2点亮,两个关键界面如下所示:

ble app

ble app

上图的Nordic UART Service(NUS)就是我们的数据透传服务, NUS具体包括两个characteristic:TX和RX,由于NUS是由设备提供的,所以TX表示设备发送数据给手机,RX表示设备接收手机发过来的数据。

  • 测试NUS服务。ble_app_uart使用串口与上位机交互,选择一款串口助手软件,比如Putty,打开该串口软件,并做如下设置:
Baud rate: 115.200kbps
8 data bits
1 stop bit
No parity
HW flow control: None

复位开发板,你会发现串口助手会打印如下信息:

ble app

重新将开发板连上手机,然后点击右上角的“Enable CCCDs”以使能notification,如下所示:

ble app

设备接收数据: 点击RX characteristic旁边的向上箭头,通过手机蓝牙往设备发送:12345678,如下所示:

ble app

此时设备通过串口打印出刚才接收到的数据,如下所示:

ble app

设备发送数据:在串口助手中输入“abcdefgh”并输入“\n”(注:在Putty中,先按“CTRL”再按“J”就会发出“\n”换行符)作为结束符,设备将把串口收到的数据通过蓝牙发送给手机,手机的TX characteristic将显示上述字符串,如下所示:

ble app

注:如果你的串口助手发不出“\n”换行符,那么你需要最少输入MTU-3个字符,设备才会把收到的全部字符通过蓝牙发出去。

通过上面的测试,大家可以发现Nordic SDK已经把蓝牙数据透传服务做好了,大家可以直接拿过来使用,下面将对其工作原理进行阐述,最后在Nordic蓝牙透传例子ble_app_uart上进行二次开发,以增加一些其他有用的功能。

3. 设备端固件代码一览

现在我们一起来看一下ble_app_uart的源代码,看看它是怎么工作起来的。首先我们来看main函数:

ble app

如上所述,ble_stack_init用于初始化配置和使能蓝牙协议栈,其代码如下所示:

ble app

其中,nrf_sdh_enable_request需要选择蓝牙协议栈的低频时钟(由于蓝牙协议栈的高频时钟必须为外部32M晶振,所以高频时钟无需配置;而低频时钟可以选择为内部32K RC或者外部32K晶振,所以低频时钟需要人工配置),因此如下宏需要根据实际情况进行调整:

nrf_clock_lf_cfg_t const clock_lf_cfg =
{
        .source       = NRF_SDH_CLOCK_LF_SRC,
        .rc_ctiv      = NRF_SDH_CLOCK_LF_RC_CTIV,
        .rc_temp_ctiv = NRF_SDH_CLOCK_LF_RC_TEMP_CTIV,
        .accuracy     = NRF_SDH_CLOCK_LF_ACCURACY
};

通过sdk_config.h文件可以看到,默认是选择外部32K晶振作为低频时钟的,如果你想选择内部32K RC作为低频时钟,那么需要做如下修改:

NRF_SDH_CLOCK_LF_SRC = 0
NRF_SDH_CLOCK_LF_RC_CTIV = 16    //每4s启动一次校准
NRF_SDH_CLOCK_LF_RC_TEMP_CTIV = 2
NRF_SDH_CLOCK_LF_ACCURACY = 1  //500ppm

nrf_sdh_ble_default_cfg_set用来配置softdevice协议栈,如下宏是经常需要修改的:

NRF_SDH_BLE_PERIPHERAL_LINK_COUNT  //作为从模式的连接同时能有几个
NRF_SDH_BLE_CENTRAL_LINK_COUNT  //作为主模式的连接同时能有几个
NRF_SDH_BLE_TOTAL_LINK_COUNT  //一共同时可以支持多少个连接,NRF_SDH_BLE_TOTAL_LINK_COUNT= NRF_SDH_BLE_PERIPHERAL_LINK_COUNT+ NRF_SDH_BLE_CENTRAL_LINK_COUNT
NRF_SDH_BLE_GATT_MAX_MTU_SIZE //MTU为多大
NRF_SDH_BLE_GAP_DATA_LENGTH //链路层数据包的最大长度,它的值要大于NRF_SDH_BLE_GATT_MAX_MTU_SIZE
NRF_SDH_BLE_GAP_EVENT_LENGTH /*一次连接间隔中分配给某个connection的物理层时间,这个时间必须大于等于NRF_SDH_BLE_GAP_DATA_LENGTH最长的包占用的时间,否则协议栈初始化会报错。如果这个时间是NRF_SDH_BLE_GAP_DATA_LENGTH的两倍甚至更多,那么在一个连接间隔中就可以连发两个甚至多个notify或者write command命令 */
NRF_SDH_BLE_VS_UUID_COUNT  //用户自定义的base UUID有几个
NRF_SDH_BLE_SERVICE_CHANGED  //要不要包含service change characteristic
NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE  /*Attribute table总共占多少蓝牙协议栈RAM空间,这个宏的取值是需要用户不断去试错的,每当你添加了或者删除了BLE service/characteristic,那么attribute table占用的RAM的空间就会变。注意:NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE是蓝牙协议栈占用的总RAM空间的一部分,所以当NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE变大时,softdevice占用的总RAM空间也会变大。Softdevice占用的总RAM空间是在编译的时候静态设置的,如下所示,本应用程序分配了0x20000000~0x20002AD8 RAM空间给协议栈(所有的nRF5芯片的RAM物理起始地址都是0x20000000),即softdevice总共占用了0x2AD8的RAM空间。而0x20002AD8往上直到RAM最高物理地址就是留给应用程序使用的,本程序分配了0xD528 RAM空间给应用程序。0x2AD8 + 0xD528 = 0x10000 = 64kB,正好是nRF52832的RAM总空间大小。*/

ble app

nrf_sdh_ble_enable真正使能Bluetooth LE功能,它的参数ram_start既是一个输入参数又是一个输出参数,作为输入参数,系统自动会把上面的RAM起始地址(0x20002AD8)传入,同时nrf_sdh_ble_enable会把softdevice当前配置情况下,它实际需要占用的RAM空间通过ram_start返回,如果这个返回值不等于输入值,那么用户需要把上图的IRAM1起始地址修改成它的返回值。这里面又分两种情况:如果返回值大于输入值,那么必须调整IRAM1起始地址,否则协议栈初始化失败;如果返回值小于输入值,这种情况也推荐去调整IRAM1起始地址,当然如果你不调整IRAM1起始地址也没有关系,因为这种情况表示协议栈实际需要的RAM的空间小于你分配给它的RAM空间,所以协议栈初始化不会出问题,这种情况只是会浪费一些RAM空间而已。请注意,任何协议栈配置宏的修改,包括NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE,都有可能导致协议栈占用的RAM空间发生变化,进而需要去调整IRAM1起始地址。一种常见的场景是,用户添加了某个service,从而需要先增大NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE这个宏的值,然后通过nrf_sdh_ble_enable得到ram_start实际起始地址,最后把ram_start的值赋给IRAM1起始地址。

NRF_SDH_BLE_OBSERVER用来为本地文件(此处为main.c文件)注册一个Bluetooth LE回调函数(此处为ble_evt_handler),NRF_SDH_BLE_OBSERVER这个宏执行成功后,所有的Bluetooth LE事件都会被ble_evt_handler捕获。进入ble_evt_handler,你会发现Bluetooth LE有上百个回调事件,你不需要每个都处理,你只需要处理你关心的事件即可,比如连接成功事件BLE_GAP_EVT_CONNECTED或者连接断开事件BLE_GAP_EVT_DISCONNECTED。NRF_SDH_BLE_OBSERVER有一个很大的好处:某个文件如果需要捕获Bluetooth LE事件,那么它只需在本文件中某处(可以在函数内也可以在函数外)调用NRF_SDH_BLE_OBSERVER这个宏去注册一个回调函数即可,而不再需要在其它文件中去注册这个回调函数,将模块的耦合性降到最低,符合模块化编程思想。

gap_params_init用来修改广播名字和连接间隔的。gatt_init用来修改底层数据包长度的。advertising_init用来修改广播包内容,广播间隔以及广播超时时间。conn_params_init用来请求更新连接间隔的。

下面我们来重点讲一下services_init,services_init用来添加服务和characteristic,“低功耗蓝牙ATT_GATT_Profile_Service_Char规格解读”讲了那么多的概念和理论,现在我们就来看看services_init是如何做到跟理论一致的。services_init通过ble_nus_init添加了一个蓝牙数据透传服务:NUS,那ble_nus_init是怎么将NUS服务添加成功的呢?查看ble_nus_init函数体,你会发现它是分三步来做的:

  1. 添加base UUID。如果是蓝牙标准UUID,这步可以省略。由于NUS不是蓝牙联盟定义的,所以需要调用sd_ble_uuid_vs_add以添加一个供应商自定义的UUID。
  2. 添加服务本身。直接调用sd_ble_gatts_service_add就可以完成。
  3. 添加服务下面的characteristics。服务的characteristic现在可以通过characteristic_add直接添加完成(characteristic_add最终是通过调用sd_ble_gatts_characteristic_add实现自己目的的)。以NUS的TX characteristic添加为例,其对应代码为:
 characteristic_add(p_nus->service_handle, &add_char_params, &p_nus->tx_handles)

其中,p_nus->service_handle表示该characteristic属于那个service,p_nus->tx_handles是输出参数,由协议栈返回,以后访问该characteristic都是通过这些句柄来完成,p_nus->tx_handles是一个结构体,它包含如下成员变量:

typedef struct
{
uint16_t value_handle; /**< Handle to the characteristic value. */
uint16_t user_desc_handle; /**< Handle to the User Description descriptor, or @ref BLE_GATT_HANDLE_INVALID if not present. */
uint16_t cccd_handle; /**< Handle to the Client Characteristic Configuration Descriptor, or @ref BLE_GATT_HANDLE_INVALID if not present. */
uint16_t sccd_handle; /**< Handle to the Server Characteristic Configuration Descriptor, or @ref BLE_GATT_HANDLE_INVALID if not present. */
} ble_gatts_char_handles_t;

add_char_params (类型为ble_add_char_params_t)是对characteristic的参数进行赋值,如“低功耗蓝牙ATT_GATT_Profile_Service_Char规格解读”所述,characteristic包含多个attribute,每个attribute都有自己的value/handle/uuid/permission,所以ble_add_char_params_t这个结构体设计的比较复杂。这里需要大家明白的是,characteristic核心attribute是value attribute,所以当我们讲characteristic的时候,其实隐含是在说value attribute。换句话说,我们定义characteristic的参数,其实是在定义value的参数,比如我们定义characteristic的访问权限,其实就是指value的访问权限。add_char_params参数解读如下:

ble app

TX characteristic只需赋值上述参数即可,但对某些其他characteristic,它需要赋值的参数会更多,这里再罗列一下ble_add_char_params_t这个结构体其他一些关键参数:

ble app

  • is_defered_read/ is_defered_write,即authorize read/write,即在read/write characteristic value之前,先进入用户回调函数,由用户回调函数决定这个read/write操作是否允许,以及value最终为多少。
  • is_value_user,默认情况下所有attribute/characteristic是存放在协议栈专用RAM中(即上述的IRAM1起始地址以下地方),这种做法的好处是所有attribute由协议栈自动管理,用户无需操心。但有时候,用户想自己控制某个characteristic,那么这个时候就可以把is_value_user设为1,将其放在应用程序RAM空间。is_value_user和is_defered_read配合使用,可以达到一些意想不到效果。

这里需要特别提醒大家的是,虽然Nordic API结构体参数设计得很复杂,但是大部分成员变量都直接采用0作为它的默认值,换句话说,大家只需对自己感兴趣的变量进行赋值即可,其他的变量可以直接采用默认值,因此大家经常会看到如下操作场合,即先用memset将该结构体变量初始化为0,让所有成员变量都采用默认值,然后再对某些需要修改的成员变量进行二次赋值。大家一定不要忘了将结构体变量清零这一步操作!

ble app

ble_nus_init同时注册了nus_data_handler回调函数,当设备收到手机发过来的数据时,就会触发nus_data_handler,用户可以在nus_data_handler中对接收到的数据进行处理,本例程中nus_data_handler直接将ble收到的数据通过uart口转发出去。如果用户需要发送数据给手机,在连接成功和notify使能的情况下,直接调用ble_nus_data_send即可,而ble_nus_data_send又是通过调用协议栈API:sd_ble_gatts_hvx来实现数据发送功能的。那么什么时候需要发送数据给手机?本例程的做法是,当串口有数据过来并满足如下条件时调用ble_nus_data_send:

if ((data_array[index - 1] == '\n') || (index >= (m_ble_nus_max_data_len)))

即遇到换行符或者字符数达到MTU,设备才会把串口收到的数据发给手机,这个测试的时候请注意一下。

main函数最后将调用API让协议栈跑起来,如果你的设备是一个从设备(peripheral),那么请调用ble_advertising_start,ble_advertising_start将开启可连接的广播,从而让你的设备连接成功之后成为从设备。如果你的设备是一个主设备(central),那么请调用sd_ble_gap_scan_start,sd_ble_gap_scan_start将开启设备的扫描功能,从而让你的设备连接成功之后变为主设备。

最后我们来看main循环,它只有一个函数: idle_state_handle,idle_state_handle先把需要打印的日志打印完,然后让系统进入idle状态(Nordic SoC spec称其为System ON状态),一旦有协议栈事件或者中断事件发生,系统将唤醒,以处理相关事件回调函数,然后再执行一遍idle_state_handle。注意:在idle状态下,蓝牙连接或者广播可以正常进行而不受影响,蓝牙连接或者广播都是周期性的,在一个周期中,蓝牙连接或者广播只持续很短一段时间(几百微妙到几毫秒,与数据包长度有关),CPU或者系统只会在这段时间内工作,其余时间系统都是处于idle状态的,比如广播间隔200ms,CPU或者系统不是持续工作200ms,他们其实只工作几百微妙,大部分时间(199ms多)系统都是处于idle状态的,这就是为什么广播或者连接状态系统平均电流很低的原因。如下为广播间隔200ms时,系统的实时电流波形:

ble app

如下为200ms连接间隔时对应的实时电流波形:

ble app

4. 定制你的Bluetooth LE数据透传应用程序

我们现在在ble_app_uart基础上加入一些定制功能。很多Bluetooth LE应用场合,都需要快速地把大量数据从设备上传给手机,我们现在模拟这种应用场景,看一下如何修改原始的ble_app_uart以达到我们的目的。

这里假设有一个timer,这个timer每7ms发送一次数据,以模拟传感器等每7ms生成一次数据情况,我们现在来看看如何让这些数据快速发出去并且不发生丢包。这个Timer的ID设为m_timer_speed,它的回调函数为:throughput_timer_handler。原始数据假设放在数组m_data_array,数组的长度正好等于MTU长度的倍数。也就是说,每隔7ms,进入一次throughput_timer_handler,在这个handler中需要把m_data_array中的数据全部正确发出去,这个就是我们的需求。假设m_data_array数据长度为420字节(m_data_array的长度依情况而定,这里只是一个假设),那么此时就需要每7ms发送420字节数据,相当于420B/7ms = 60kB/s。

蓝牙传输速度跟得上吗?怎么做可以满足这个需求呢?在讲解参考代码之前,我们先看一些反例或者误区,这样或许更能帮助大家去理解相关工作机理。

4.1 一些使用上的误区

大家首先想到的做法就是在throughput_timer_handler直接调用 ble_nus_data_send,如下

static void throughput_timer_handler(void * p_context)
{
  err_code = ble_nus_data_send(&m_nus, m_data_array, &length, m_conn_handle);
}

这种做法首先存在如下两个大问题:

  • 没有检测返回值err_code。实际上,在这种情况下调用ble_nus_data_send,ble_nus_data_send经常返回NRF_ERROR_RESOURCES,只要err_code不是NRF_SUCCESS,就意味着该数据没有成功放入射频FIFO中,从而出现所谓“丢包”现象。不是包在空中丢了,而是包没有正确放入射频FIFO中,这个就是丢包的原因。
  • length变量没有进行最大值限制。ble_nus_data_send不能发送任意长度的数据包,它只能发送小于等于MTU长度的数据包。

如果我们不对返回值进行检测,同时把length设为MTU,然后每调用一次ble_nus_data_send,执行一次m_len_sent += length,即代码如下所示(代码片段1):

static void throughput_timer_handler(void * p_context)
{
    ret_code_t err_code;
    uint16_t length;
    m_cnt_7ms++;
   //sending code lines
   length = m_ble_nus_max_data_len;
   //new data
   m_data_array[0]++;
   m_data_array[length-1]++;
   err_code = ble_nus_data_send(&m_nus, m_data_array, &length, m_conn_handle);
   m_len_sent += length;
   //calculating data throughput
   NRF_LOG_INFO("time: %d *7ms == bytes send: %d Bytes == avg speed: %d B/s",m_cnt_7ms,m_len_sent,(m_len_sent * 1000)/(m_cnt_7ms*7));
}

我们可以看到如下日志:

ble app

由上图可知,数据上传吞吐率达到了34.8kB/s,看起来不错,其实这个吞吐率是假的,因为中间丢了很多包,但计算吞吐率的时候把丢的包也算进去了。如下图手机端nRF Connect日志所示,0x6E之后应该为0x6F,但实际发送的数据包编号为0x83,丢包非常严重。

ble app

为了防止所谓的“丢包”(前面也提过,这里的丢包不是数据包在空中丢掉了,而是数据包没有安全送到协议栈的buffer中,从而导致丢包),我们加上如下if语句(代码片段2),只有ble_nus_data_send返回正确时,才认为数据包正确发送,然后才能算入到最后的数据吞吐率中。

 err_code = ble_nus_data_send(&m_nus, m_data_array, &length, m_conn_handle);
 if (err_code == NRF_SUCCESS)
 {
    m_len_sent += length;
    //new data
    m_data_array[0]++;
    m_data_array[length-1]++;
}

m_data_array只有在返回成功时才自增(自增表示新数据有效),所以新数据生成速度会比较慢,从而导致数据吞吐率下降。通过查看nRF connect日志,你会发现此时不会发生丢包了,但吞吐率直接降到了4kB/s左右。

之前我们也讲过,为了提高吞吐率,我们可以在一个连接间隔中发多个包。如何可以做到一个连接间隔中发送多个包?除了协议栈需要做一些额外的配置,代码层面可以这么做(代码片段3):

do
    {
        err_code = ble_nus_data_send(&m_nus, m_data_array, &length, m_conn_handle);
        if ( (err_code != NRF_ERROR_INVALID_STATE) && (err_code != NRF_ERROR_RESOURCES) &&
                 (err_code != NRF_ERROR_NOT_FOUND) )
        {
                APP_ERROR_CHECK(err_code);
        }
        if (err_code == NRF_SUCCESS)
        {
            m_len_sent += length;
            m_data_array[0]++;
            m_data_array[length-1]++;
        }
    } while (err_code == NRF_SUCCESS);

测试下来,你会发现上面两份代码(代码片段2和代码片段3)测试结果是一样的。那么问题到底出在哪?下面进行解读。

4.2 参考代码解读

欲达到最大的数据吞吐率,必须保证蓝牙带宽尽可能被利用,换句话说,必须保证蓝牙包尽可能占满整个时间轴,如下所示:

ble app

反之,如果连接间隔很长,而且一个间隔只发一个包,那么哪怕这个包包长为251字节,吞吐率也不可能高,如下所示:

ble app

粗略来说,

蓝牙数据传输吞吐率 = 一个连接间隔传输的数据 / 连接间隔 = 一个数据包长度 * 包数 / 连接间隔

比如连接间隔设为60ms,一个间隔只传一个244字节的数据包,此时吞吐率为:244B/60ms = 4.1 kB/s,这就是4.1节数据传输速率上不去的根本原因。

实际情况比上面公式复杂得多,但是我们做理论分析时可以以这个公式作为参考。从上面公式可知,欲提高数据吞吐率,可以从数据包长度,每个连接间隔发送的总包数,以及连接间隔等三个方面入手,这里需要提醒大家的是,不是包长越长越好,也不是间隔越短越好,必须将三者统一起来一起考虑。如果发送短包时能保证一个间隔发送很多短包,那么此时短包有可能比长包速率还要快;如果连接间隔大时能保证一个间隔发送很多的数据包,那么此时大的连接间隔速率有可能比小的连接间隔还要快。针对不同的手机,不同的蓝牙设备,大家一定要统筹好这三个参数,让他们整体达到一个最优值,从而得到你的应用场景下的最高吞吐率。下面看看我们的参考代码如何调整这三个参数的。

一个数据包的长度取决于下面两个参数:

#define NRF_SDH_BLE_GAP_DATA_LENGTH 251
#define NRF_SDH_BLE_GATT_MAX_MTU_SIZE 247

NRF_SDH_BLE_GAP_DATA_LENGTH ≥ (NRF_SDH_BLE_GATT_MAX_MTU_SIZE+4) ,NRF_SDH_BLE_GAP_DATA_LENGTH最大值为251,这里MTU设为247,也就相当于把数据包应用数据最大长度设为247-3=244字节

连接间隔由下面两个参数建议:

#define MIN_CONN_INTERVAL               MSEC_TO_UNITS(30, UNIT_1_25_MS)
#define MAX_CONN_INTERVAL               MSEC_TO_UNITS(30, UNIT_1_25_MS)

上面把最小连接间隔和最大连接间隔都设为30ms,请注意最终连接间隔设为多少只能由蓝牙主设备决定,蓝牙从设备只有建议权。由于我们的设备是一个从设备,所以上面就是对主机提出建议:希望主机把连接间隔设为30ms,主机可以接受也可以拒绝这个请求。

一个连接间隔可以发多个包由下面参数决定:

#define NRF_SDH_BLE_GAP_EVENT_LENGTH 24

NRF_SDH_BLE_GAP_EVENT_LENGTH的单位是1.25ms,所以这里的NRF_SDH_BLE_GAP_EVENT_LENGTH是30ms,如果连接间隔也为30ms,那就意味着整个连接间隔都可以用来发送蓝牙数据包,大家知道一个251字节的蓝牙数据包和它的ACK包总共在空中大概持续2.5ms时间,这样我们可以大概估算30ms连接间隔中理论上可以发的包数:NRF_SDH_BLE_GAP_EVENT_LENGTH / 2.5ms = 12,虽然NRF_SDH_BLE_GAP_EVENT_LENGTH等于连接间隔,但不表示整个连接间隔都可以用来发送蓝牙数据包,因为每个连接间隔还需要预留一些时间给协议栈调度,射频初始化,以及应用程序执行,这个时间假设为3ms,那么上面的配置情况下,30ms连接间隔理论上可以发送的最大包数为:(30-3)/2.5 = 10,即理论上蓝牙数据传输最高速度可以达到:(244*10)/30ms = 81kB/s。

回到上一节问题,为什么代码片段3和代码片段2测试结果差不多呢?因为代码片段3没有对协议栈进行额外配置,所以传输速率起不来。现在我们把上面的配置加上去,看一下代码片段3传输速率能到多少?

ble app

数据吞吐率只有32kB/s,跟理论值81kB/s还是差了不少,这个又是什么原因呢?

为了分析问题,我们把打印方式改成如下方式:

 if (m_cnt_7ms == 143)
    {
        NRF_LOG_INFO("==**Speed: %d B/s**==", m_len_sent);
        m_cnt_7ms = 0;
        m_len_sent = 0;
        m_data_array[0] = 0;
        m_data_array[length-1] = 0;
    }
    NRF_LOG_INFO("PacketNo.: %d == Time: %d *7ms", m_data_array[0], m_cnt_7ms);

相应打印日志如下所示:

ble app

可以看出,我们的设备每49ms才发了4个包或者5个包,这就是为什么它的吞吐率只有30kB/s左右的原因。49ms发4个包或者5个包,到底是协议栈配置问题,还是手机端限制问题,抑或是应用程序逻辑问题?从上面的日志我们可以推出,协议栈射频FIFO为4(这个是日志给我们的推论,我们也暂且这么认为),而不是我们预想的10,换句话说,30ms连接间隔最多发4个包,即244*4/30ms = 32kB/s,正好跟我们实测值差不多。

如果我们把连接间隔改为14ms,那么吞吐率是不是可以达到:244*4/14ms = 69.7kB/s

ble app

可以看到,基本上达到预期了。大家可以尝试把连接间隔进一步减少,看一下吞吐率会不会进一步提高?答案是否,具体原因大家可以自己去想一下。

但是60kB/s跟我们的理论速度80kB/s相比,还是相差有一点大,有没有办法能不能进一步提高吞吐率?我们现在把连接间隔重新改为30ms,然后在nus_data_handler中加入如下代码:

else if (p_evt->type == BLE_NUS_EVT_TX_RDY)
        {
            ret_code_t err_code;
            uint16_t length;
            //sending code lines
            length = m_ble_nus_max_data_len;
            do
            {
                err_code = ble_nus_data_send(&m_nus, m_data_array, &length, m_conn_handle);
                if ( (err_code != NRF_ERROR_INVALID_STATE) && (err_code != NRF_ERROR_RESOURCES) &&
                         (err_code != NRF_ERROR_NOT_FOUND) )
                {
                        APP_ERROR_CHECK(err_code);
                }
                if (err_code == NRF_SUCCESS)
                {
                    m_len_sent += length;
                    m_data_array[0]++;
                    m_data_array[length-1]++;
                }
            } while (err_code == NRF_SUCCESS);
        }

ble_nus_data_send 对应的ATT PDU是notification,notification是没有response的,但它还是有ACK的,ACK表示对方已经正确收到该包。在Nordic SDK中,BLE_NUS_EVT_TX_RDY事件即表示notification的ACK包,也就是说,每收到BLE_NUS_EVT_TX_RDY这个事件,表示一个或者多个notification包已经被对方正确接收。假设协议栈射频FIFO为10并且已满,此时协议栈将自动将这10个包发出去,每发出去1个包或者多个包(这里用n替代),产生一次BLE_NUS_EVT_TX_RDY回调事件,此时FIFO中还有10-n个包待发送,换句话说,射频FIFO又可以重新入队n个新的数据包进来。通过这种方式,我们可以让一个连接间隔所有时间都用来发数据包,直至下一个连接间隔。

下面为新代码对应的日志,由日志可见,设备在30ms的连接间隔中发出了10个蓝牙包,说明之前的协议栈配置没有问题,手机也支持10个蓝牙包,是我们的应用逻辑出问题了,没有让协议栈跑到极限。

ble app

如下所示,这种情况下蓝牙数据传输速率可以达到80kB/s左右,基本上快接近蓝牙4.2的理论吞吐率了。

ble app

4.3 蓝牙5.0高速率模式

蓝牙5.0提出了一个高速率模式,即引入了一个新的调制解调符号率:2Msps,即1秒钟可以传2M个bit,一个bit传输只需1/2M=0.5us。蓝牙4.x低功耗模式只支持1Mbps,也就是说传输1bit需要1us时间。采用2M高速率模式,传输同样长度的蓝牙数据包,所花费的时间只有1M模式的一半,换句话说,同样的连接间隔中,2M模式可以发出的数据包基本上是1M模式的两倍,根据吞吐率计算公式:

蓝牙数据传输吞吐率 = 一个数据包长度 * 包数 / 连接间隔

包数翻倍,在其他条件不变的情况下,相当于数据传输速率翻倍。

只有手机和蓝牙设备同时支持2M模式,并且有一方主动要求更新物理层为2M时,蓝牙通信才会采用2M,上述的速率翻倍才可能实现。

Nordic所有的nRF52设备都支持高速率2M模式,而手机是否支持2M模式,可以通过手机版nRF Connect的 “Device information” 菜单查看,比如我的手机显示如下信息,说明它是支持2M模式的。

ble app

硬件已经满足了,那软件需要如何修改呢?2M或者1M模式,这个是物理层的概念,对应用来说是完全透明的,也就是说,不管采用2M还是1M模式,应用代码都是一模一样,不需做任何修改。ble_app_uart已经支持2M模式了,相关代码如下所示:

        case BLE_GAP_EVT_PHY_UPDATE_REQUEST:
        {
            NRF_LOG_DEBUG("PHY update request.");
            ble_gap_phys_t const phys =
            {
                .rx_phys = BLE_GAP_PHY_AUTO,
                .tx_phys = BLE_GAP_PHY_AUTO,
            };
            err_code = sd_ble_gap_phy_update(p_ble_evt->evt.gap_evt.conn_handle, &phys);
            APP_ERROR_CHECK(err_code);
        } break;

phy参数有4种:

  • BLE_GAP_PHY_1MBPS,强制选择1M模式
  • BLE_GAP_PHY_2MBPS,强制选择2M模式
  • BLE_GAP_PHY_CODED,强制选择低速率的长距离模式
  • BLE_GAP_PHY_AUTO,协议栈自动选择合适的phy层,一般会选择最快最合适的那个phy

请注意:BLE_GAP_EVT_PHY_UPDATE_REQUEST是响应主机端的phy update请求的,如果主机端不发phy update请求,那么phy仍然维持默认的1M模式。碰到这种情况,设备端需要主动发起phy update请求,怎么做呢?只需在连接成功事件中,调用sd_ble_gap_phy_update这个函数即可,这样哪怕手机端不发起phy update请求,设备端也会主动请求phy update。

4.2节的参考代码我都是把phy强制设为BLE_GAP_PHY_1MBPS,现在我们把它重新设为BLE_GAP_PHY_AUTO,然后手机端主动发起2M phy update请求,如下所示:

ble app

此时蓝牙数据吞吐率如下所示:146kB/s,快接近1M模式下的吞吐率的两倍,符合我们的预期。

ble app

4.4 更接近实际应用场景的参考代码

上面的代码只是为了测试蓝牙数据通信最高速率能到多少,但用户的实际应用场景与此差别很大,主要差别就是数据生成方式。上面的代码有效数据生成方式非常简单:直接对原始的数组元素进行自增,实际应用场景数据都是成块生成的,我们这里假设每7ms生成420B字节数据,换句话说,蓝牙数据传输速率必须达到60kB/s,才能把数据全部正确发出去,而不出现所谓丢包或者不同步现象。

在代码中,每生成一个420B数据,我们将其拆成2包数据放到队列(queue)中,然后在7ms timer中断中以及BLE_NUS_EVT_TX_RDY回调中,我们会去查询queue是否为空,如果不空就把队列中的数据通过蓝牙发出去,核心代码如下所示:

    while (!nrf_queue_is_empty(&m_buf_queue) && !retry)
    {
        err_code = nrf_queue_pop(&m_buf_queue, &m_buf);
        APP_ERROR_CHECK(err_code);
        length = m_buf.length;
        err_code = ble_nus_data_send(&m_nus, m_buf.p_data, &length, m_conn_handle);
        //NRF_LOG_INFO("Data: %d", m_buf.p_data[0]);
        if ( (err_code != NRF_ERROR_INVALID_STATE) && (err_code != NRF_ERROR_RESOURCES) &&
                 (err_code != NRF_ERROR_NOT_FOUND) )
        {
                APP_ERROR_CHECK(err_code);
        }
        if (err_code == NRF_SUCCESS)
        {
            m_len_sent += length;
            retry = false;
        }
        else
        {
            retry = true;
            break;
        }
    }

所有相关代码我们都是通过APP_QUEUE这个宏来控制的,大家可以自己去看一下相关代码,这里就不再解读了。

经过测试我们发现(代码需要打开APP_QUEUE这个宏),当手机亮屏的时候(此时手机需要协调WiFi和蓝牙活动),丢包现象时有发生;当手机息屏的时候,基本上没有丢包现象发生,日志如下所示:

ble app

这是一个非常接近实际应用场景的代码,大家可以直接参考它来完成自己的开发。这里要注意一点,由于环境的复杂性以及手机需要调度多种射频活动,“丢包”不可避免,大家需要做的是,当“丢包”发生时,即上文的queue发生溢出时,该如何处理?一般而言,都是舍弃相关数据以让程序可以正常往下跑。

上述所有代码都已上传到百度网盘,网盘地址见文章最开始的地方。

5. 如何测试Bluetooth LE设备的稳定性

在开发蓝牙设备固件的时候,不可避免需要用手机对其进行测试,尤其需要对其进行稳定性测试。一般而言,固件开发和手机app开发是相互独立的,很多时候我们会碰到固件开发差不多了但手机app还没有开发好,这种情况下怎么测试固件和手机交互的功能和稳定性?答案是nRF connect手机版。nRF connect很多功能都简单明了,一看就会,大家可以用它们来做功能性测试。这里我们讲一下nRF connect的宏录制功能,大家可以用宏录制功能来测试Bluetooth LE通信的稳定性。强调一下,宏录制功能目前只有安卓版nRF Connect支持,iOS版nRF Connect还不支持这个特性。

5.1 手机端宏录制方式

相信到现在大家对Bluetooth LE数据上传机理和实践有个大概的了解,那如何测试Bluetooth LE数据下行性能,即怎么测试数据从手机传到设备的稳定性和可靠性?我们是不是必须开发一款手机app来进行相关测试呢?其实不用的,大家可以直接使用Nordic的nRF connect手机版来与你的设备进行交互,从而完成基本功能验证,同时可以使用nRF connect宏录制功能来对设备进行稳定性测试和压力测试。下面我们来讲讲宏录制是怎么工作的。

所谓宏录制,就是把你对nRF connect的操作录制下来,然后通过宏播放实现自动化操作。由于nRF connect是一个容器,并支持JavaScript和HTML语法,宏其实就是一个XML脚本,nRF connect定义了自己的一套XML标签操作,遵守这套XML标签操作,就可以对nRF connect进行自动化操作。nRF connect支持的所有XML语法都在手机安装目录\Nordic Semiconductor中的示例中体现,只要示例中出现过的标签就支持,相反示例中没有的标签就不支持。下面具体讲一下宏录制的操作过程。

当nRF connect连接设备成功后,你会发现右下角有一个红点,那个就是宏录制菜单。

ble app

点击下面的红点,我们开始宏录制操作

ble app

然后我们按照普通操作来操作nRF connect,这些操作最终对应的Bluetooth LE指令会被录制下来,以便后续重复播放。我们先把“1234”发送给设备,如下:

ble app

发送完上述指令后,我们加一个300ms的延时,如下:

ble app

然后我们点击完成按钮,保存该宏,可以看出这个宏包括两条操作:发送“1234”到设备,然后睡眠300ms。

ble app

将宏命名为“test”并保存:

ble app

到此宏已经录制成功了,现在我们开始展示宏的神奇功能。如下,选择循环播放模式,然后点击“开始”按钮开始循环播放该录制宏。

ble app

大家可以看到,nRF connect先执行“Write 0x31323334 to RX characteristic”,然后睡眠300ms,然后又执行“Write 0x31323334 to RX characteristic”,如此循环往复。打开串口助手,你会发现设备已经收到了手机发过来的一连串“1234”,如下。

ble app

我们把刚才的test宏导出为XML,看一看它到底长什么样:

<macro name="test" icon="PLAY">
   <assert-service description="Ensure Nordic UART Service" uuid="6e400001-b5a3-f393-e0a9-e50e24dcca9e">
      <assert-characteristic description="Ensure RX Characteristic" uuid="6e400002-b5a3-f393-e0a9-e50e24dcca9e">
         <property name="WRITE" requirement="MANDATORY"/>
      </assert-characteristic>
   </assert-service>
   <write description="Write 0x31323334 to RX Characteristic" characteristic-uuid="6e400002-b5a3-f393-e0a9-e50e24dcca9e" service-uuid="6e400001-b5a3-f393-e0a9-e50e24dcca9e" value="31323334" type="WRITE_REQUEST"/>
   <sleep description="Sleep 300 ms" timeout="300"/>
</macro>

大家可以看到,宏就是一些XML标记,大家也可以在此基础上,去修改该XML文件,以实现更复杂的自动化测试,然后通过nRF connect把最新的XML文件装载进来,就可以自动播放了。

如果你还想了解宏更多的用法信息,请参考:https://github.com/NordicSemiconductor/Android-nRF-Connect/blob/master/documentation/Macros/README.md

5.2 电脑端XML方式

前面的宏录制方式,功能还是比较单一,如果要实现更复杂的自动化测试,可以通过在PC端执行XML脚本方式来实现(这个XML格式跟上面的XML是有差别的)。通过安卓调试工具ADB,我们可以直接通过PC来操作nRF connect,这样就可以让nRF connect按照XML脚本意图去执行相关自动化操作。nRF connect支持的所有XML语法都在手机安装目录中(手机内部存储/ Nordic Semiconductor目录)的示例中体现,只要示例中出现过的标签就支持,相反示例中没有的标签就不支持。

欲了解更多信息请参考:https://github.com/NordicSemiconductor/Android-nRF-Connect/blob/master/documentation/Automated%20tests/README.md

6. 开发手机端app代码

Nordic提供很多手机端开源app供大家参考,用得最多的就是nRF Toolbox和nRF Blinky(注:nRF connect代码不开源),在nRF Toolbox和nRF Blinky中都有相关的Bluetooth LE操作库,尤其是nRF Toolbox包含了很多Bluetooth LE库,比如Bluetooth LE管理,DFU,数据透传,蓝牙Mesh等等,大家可以参考他们来开发自己的手机端app。

nRF Toolbox软件界面如下所示:

ble app

UART就是前文说的NUS服务,除了nRF connect,其实大家也可以通过nRF Toolbox UART模块来完成第2章所述的操作。nRF Toolbox另一个用的比较多的功能就是DFU,如果你需要通过手机Bluetooth LE来实现设备固件的空中升级(OTA),那么可以参考nRF Toolbox DFU模块来编写你的手机端app软件。

订阅Nordic新闻简报

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

立即订阅