如何开发BLE数据透传应用程序?什么是BLE service和characteristic?如何开发自己的service和characteristic?如何区分ATT和GATT?有没有什么工具可以对BLE设备进行压力测试?如何提高BLE设备的数据上传速度?本文将对以上问题进行解答。
在很多应用场合,BLE只是作为一个数据透传模块,即将设备端数据上传给手机,同时接收手机端下发的数据。本文将和大家一起,一步一步演示如何开发一个BLE透传应用程序。按照本文的说明,大家可以很快就实现一个BLE透传应用,BLE透传应用已经是BLE应用中比较复杂的一种,一旦大家掌握了BLE透传应用,其他BLE应用开发就更不在话下了。本文还会以BLE透传为例子,来解释BLE service和characteristic等概念,以帮助大家理解如何定义和开发自己的BLE service和characteristic等,从而彻底理解BLE协议栈中的ATT和GATT的运行原理。然后,本文还将手把手教大家如何提高BLE数据传输速度(蓝牙4.2的理论吞吐率大概为100kB/s,而我们实际达到了80kB/s,已经非常接近理论值)。最后,我们将告诉大家如何使用安卓版nRF Connect来对你的BLE设备进行压力测试,以测试设备的稳定性和可靠性。当然,文章的最后也会告诉大家如何找到安卓和iOS手机app开发参考代码。
1. 开发准备
- Nordic nRF52或者nRF51开发板1块。请参考“Nordic nRF51/nRF52开发流程说明”,购买相应开发板(DK)。
- 开发环境搭建。简述如下(详细说明请参考“Nordic nRF51/nRF52开发环境搭建”):
- 1.安装Keil5 MDK
- 2.安装SDK。如果你使用的是nRF52开发板,请安装nRF5 SDK15.0.0,下载链接:https://www.nordicsemi.com/eng/nordic/download_resource/59012/70/52858981/116085。如果你手上是nRF51开发板,请下载nRF5 SDK12.3.0:https://www.nordicsemi.com/eng/nordic/download_resource/54280/56/38442131/32925 (nRF51最高SDK版本只能到12.3.0,后续SDK就不再支持nRF51了)
- 3.安装ARM CMSIS4.5.0,下载链接:https://github.com/ARM-software/CMSIS/releases/download/v4.5.0/ARM.CMSIS.4.5.0.pack。
- 4.安装Keil5 Device Family Pack,下载链接:https://www.nordicsemi.com/eng/nordic/download_resource/58865/28/26535159/87790。
- 5.安装nRF5 Command Line Tools,下载链接(Windows版):https://www.nordicsemi.com/eng/nordic/download_resource/58850/47/60411125/53210。
- 6.安装安卓版或者iOS版nRF connect。iOS版nRF connect请到苹果app store下载,搜索“nRF”即可以找到。安卓版nRF connect可以到Nordic Github官网上下载,下载链接为:https://github.com/NordicSemiconductor/Android-nRF-Connect/releases
- 7.安装PC版nRF connect或者nRFgo studio,两个选其一即可。PC版nRF connect下载链接(Windows版):https://www.nordicsemi.com/eng/nordic/download_resource/58847/15/21277021/108233。
注:如果你使用的是Linux系统/Mac系统,或者你使用的不是Keil5-MDK,请参考“Nordic nRF51/nRF52开发环境搭建”来搭建你的开发环境。
2. 运行Nordic ble_app_uart应用程序
Nordic SDK已经提供了一个直接就可以编译和运行的数据透传应用程序:ble_app_uart,Nordic将BLE透传服务称为Nordic UART Service(NUS),所以在Nordic SDK中,NUS就是BLE透传服务。请按照如下步骤运行SDK自带的ble_app_uart程序:
- 确认自己的芯片型号或者开发板。如果采用Nordic官方开发板的话,芯片型号和开发板编号对应关系如下:
- nRF51系列对应开发板编号为PCA10028
- nRF52832和nRF52810对应开发板编号为PCA10040。虽然52832和52810共用同一块开发板,但是他们在SDK中的项目编号是不一样的,52832对应PCA10040目录,52810对应PCA10040e目录,由于52810和52832 PIN to PIN兼容,软件也是完全兼容的,因此SDK很多项目只有PCA10040的目录,而没有PCA10040e目录,此时需要你自己来建立PCA10040e对应的目录和工程,具体说明可参考:http://infocenter.nordicsemi.com/index.jsp?topic=%2Fcom.nordic.infocenter.sdk5.v15.0.0%2Fnrf52810_user_guide.html&cp=4_0_0_5_0。
- nRF52840对应开发板编号为PCA10056
- nRF52840 dongle编号为PCA10059
这里我会以nRF52832开发板PCA10040为例来阐述整个开发过程,其他开发板与之类似,大家自己可以举一反三来开始自己的开发之旅。
- 将开发板与PC机通过USB线相连,同时打开开发板电源(将左下角的拨位开关打到“ON”位置),打开桌面版nRF Connect,选择启动“Programmer”应用,由于驱动之前已经安装好了,设备可以立即识别成功。执行“full erase”操作,以擦除芯片原始内容。
- 打开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 /协议角色/例子名称/开发板型号/协议栈型号/工具链类型/具体工程,比如下面例子:
Nordic每一个例子都支持5种工具链:Keil5/Keil4/IAR/GCC/SES,如下所示:
4) 编译程序。如果你已经按照之前的说明配置好了开发环境,那么这里编译是不会报任何错的。(如果你遇到了编译错误,请重新按照前面说明去搭建你的开发环境,不要怀疑SDK例子代码有问题哦)
- 下载程序。程序下载包括2步:一先下载softdevice,二再下载应用。Softdevice是Nordic蓝牙协议栈的名称,整个开发过程中只需下载一次。应用就是我们这里的ble_app_uart程序。如果你的开发板已经下载了其他代码,那么最好先把开发板全擦一次,然后再下载softdevice和应用。
- 芯片全擦(可选)。你可以使用nRFgo studio,或者nRF connect桌面版,或者nrfjprog,三者选其一来执行擦除操作。
- 使用nRFgo studio执行全擦操作
- 使用nRF connect桌面版执行全擦操作
- 使用nrfjprog执行全擦操作
- 蓝牙协议栈下载(整个开发周期只需下载一次)。在Keil ‘select target’下拉列表中,默认选择的是Keil工程对应的Target,即‘nrf52832_xxaa’。我们还可以选择另一个target ‘flash_s132_nrf52_6.0.0_softdevice’,即softdevice对应的target,然后点击“下载download”(不需要编译哦!),此时会把softdevice下载到开发板中。
- 应用下载。重新选择Target:‘nrf52832_xxaa’,点击“下载Download”,此时会把ble_app_uart应用程序下载到开发板中。此时开发板的LED1闪烁,表示程序运行正常。
-
连接手机。打开手机蓝牙和手机版nRF connect。在nRF connect中,你将看到一个广播设备:Nordic_UART,这个就是开发板的广播名字。点击“CONNECT”,手机将与设备建立连接,并开始服务发现过程,连接成功后,LED1熄灭,LED2点亮,最后将得到如下界面。
上图的Nordic UART Service(NUS)就是我们的数据透传服务, NUS具体包括两个characteristic:TX和RX,由于NUS是由设备提供的,所以TX表示设备发送数据给手机,RX表示设备接收手机发过来的数据。 -
测试NUS服务。ble_app_uart使用串口与上位机交互,选择一款串口助手软件,比如Putty,打开该串口软件,并做如下设置:
- Baud rate: 115.200
- 8 data bits
- 1 stop bit
- No parity
- HW flow control: None
复位开发板,你会发现串口助手会打印如下信息:
按照第6)步,重新将开发板连上手机,然后点击右上角的“Enable CCCDs”以使能notification,如下所示:
设备接收数据: 点击RX characteristic旁边的向上箭头,通过手机蓝牙往设备发送:12345678,如下所示:
此时设备通过串口打印出刚才接收到的数据,如下所示:
设备发送数据:在串口助手中输入“abcdefgh”并输入“\n”(注:在Putty中,先按“CTRL”再按“J”就会发出“\n”换行符)作为结束符,设备将把串口收到的数据通过蓝牙发送给手机,手机的TX characteristic将显示上述字符串,如下所示:
注:如果你的串口助手发不出“\n”换行符,那么你需要最少输入MTU-3个字符,设备才会把收到的全部字符通过蓝牙发出去
通过上面的测试,大家可以发现Nordic SDK已经把蓝牙数据透传服务做好了,大家可以直接拿过来使用,下面将对其工作原理进行阐述,最后在Nordic蓝牙透传例子ble_app_uart上进行二次开发,以增加一些其他有用功能。如果大家觉得Nordic ble_app_uart已经可以满足自己的需求,而且也不想花时间去研究里面的原理,那么章节3/4/5/6/7.1可以略过不看。
3. BLE client/server(C/S) 架构
BLE采用了client/server (C/S)架构来进行数据交互,C/S架构是一种非常常见的架构,在我们身边随处可见,比如我们经常用到的浏览器和服务器也是一种C/S架构,这其中浏览器是客户端client,服务器是服务端server,server比如淘宝服务器,提供商品信息,广告,社交等服务,而浏览器,比如微软的IE,就可以用来请求这些服务,并使用server提供的服务。BLE与此类似,一般而言设备提供服务,因此设备是server,手机使用设备提供的服务,因此手机是client。比如蓝牙体温计,它可以提供“体温”数据服务,因此是一个server,而手机则可以请求“体温”数据以显示在手机上,因此手机是一个client。
服务是以数据为载体的,所以说server提供服务其实就是提供各种有价值的数据。
上图所示的Request和Response其实就是我们经常说的ATT命令(ATT PDU),也就是说Client和Server之间通过ATT PDU进行交互。另外,一个数据“37”,有可能是说体温“37度”,也有可能是说心率“37次”或者湿度“37%”,因此Server需要将数据进行包装和分类,在BLE中,数据是通过characteristic进行包装的,而且多个characteristic组成一个service,service是一个独立的服务单元,或者说service是一个基本的BLE应用。因此我们可以把上图细化为:
如果某个service是一个蓝牙联盟定义的标准服务,也可以称其为profile,比如HID/心率计/体温计/血糖仪等,都是标准蓝牙服务,因此都有相应的profile规格书。
??????具体后续解答
4. BLE service, characteristic以及CCCD
如文章“深入浅出低功耗蓝牙(BLE)协议栈”所讲,BLE协议栈架构如下所示:
如上图所示,用户开发应用程序或者说service的时候,调用的都是GATT API,而GATT又调用了ATT API,前面也讲过,BLE数据最终都是通过ATT PDU来传输的,那么为什么还需要GATT层?直接操作ATT层不也可以达到同样的目的吗?
前面也提过,Server是通过characteristic来表示数据的,虽然一条数据最有价值的部分是它的值(value),但是仅有value是不够,比如27,到底是表示27°温度还是27%湿度;如果表示的是温度,那么它的单位是摄氏度还是华氏度。同时每个value还有相应的读写属性以及权限属性,因此一个characteristic包含三种条目:characteristic声明,characteristic的值以及characteristic的描述符(可以有多个描述符),如下所示:
由于一个service可以包含多个characteristic,characteristic declaration就是每个characteristic的分界符,解析时一旦遇到characteristic declaration,就可以认为接下来又是一个新的characteristic了,同时characteristic declaration还将包含value的读写属性等。Characteristic value就是数据的值了,这个比较好理解就不再说了。Characteristic descriptor就是数据的额外信息,比如温度的单位是什么,数据是用小数表示还是百分比表示等之类的数据描述信息。CCCD是一种特殊的characteristic descriptor,一般而言,都是client来访问server的characteristic,我们把这种操作称为读或者写。另外,server可以直接把自己的characteristic的值告诉client,我们称其为notify或者indicate,跟read操作相比,只有需要传输数据的时候或者说只有当数据有效时,server才开始notify或者indicate数据到client,因此这种操作方式可以大大节省server的功耗。有时候client不想监听characteristic notify或者indicate过来的数据,那么就可以使用CCCD来关闭characteristic的notify或者indicate功能;如果client又需要监听characteristic的notify或者indicate,那么它可以重新使能CCCD来打开相关操作。总结一下,当characteristic具有notify或者indicate操作功能时,那么必须为其添加相应CCCD,以方便client来使能或者禁止notify或者indicate功能。
不管是characteristic declaration,characteristic value还是characteristic descriptor,实现的时候,我们都是用attribute来表达的,也就是说,他们每一个都是一个attribute,attribute可以用下图来表示:
- Attribute handle,Attribute句柄,16-bit长度。Client要访问Server的Attribute,都是通过这个句柄来访问的,也就是说ATT PDU一般都包含handle的值。用户在软件代码添加characteristic的时候,系统会自动按顺序地为相关attribute生成句柄。
- Attribute type,Attribute类型,2字节或者16字节长。在BLE中我们使用UUID来定义数据的类型,UUID是128 bit的,所以我们有足够的UUID来表达万事万物。其中有一个UUID非常特殊,它被蓝牙联盟采用为官方UUID,这个UUID如下所示:
由于这个UUID众所周知,蓝牙联盟将自己定义的attribute或者数据只用16bit UUID来表示,比如0x1234,其实它也是128bit,完整表示为:
Attribute type一般是由service和characteristic规格来定义,站在蓝牙协议栈角度来看,ATT层定义了一个通信的基本框架,数据的基本结构,以及通信的指令,而GATT层就是前文所述的service和characteristic,GATT层用来赋予每个数据一个具体的内涵,让数据变得有结构和意义。换句话说,没有GATT层,低功耗蓝牙也可以通信起来,但会产生兼容性问题以及通信的低效率。
- Attribute value,就是数据真正的值,0到512字节长。
- Attribute permissions,Attribute的权限属性,权限属性不会直接在空中包中体现,而是隐含在ATT命令的操作结果中。假设一个attribute read属性设为open(即读操作不需要任何权限),那么client去读这个attribute时server将直接返回attribute的值;如果这个attribute read属性设为authentication(即需要配对才能访问),如果client没有与server配对而直接去访问这个attribute,那么server会返回一个错误码:告诉client你的权限不够,此时client会对server发起配对请求,以满足这个attribute的读属性要求。目前主要有如下四种权限属性:
- Open,直接可以读或者写
- No Access,禁止读或者写
- Authentication,需要配对才能读或者写,由于配对有多种类型,因此authentication又衍生多种子类型,比如带不带MITM,有没有LESC
Authorization,跟open一样,不过server返回attribute的值之前需要应用先授权,也就是说应用可以在回调函数里面去修改读或者写的原始值。 - Signed,签名后才能读或者写,这个用得比较少。
大家还记不记得设备与手机nRF connect连接成功后呈现的界面,我这里再贴一下:
可以看到手机呈现的就是上文讲的service和characteristic,nRF Connect为了让整个界面变得更美观,将访问属性,UUID,handle都分列来表示了,以致于很多初学者会把理论和现实二者对应不起来。Nordic之前推出过一款Master Control Panel(MCP),MCP现在已经不推荐使用了,不过MCP有一个好处,它对service和characteristic的组织方式更接近底层实现方式,对大家理解service和characteristic是非常有帮助的。还是这个设备,我用MCP跟它连接并进行服务发现,你会发现它呈现的界面如下所示:
这个图就跟上面讲的理论知识可以一一对应起来了,NUS包含2个characteristic:RX和TX,每一个条目都是一个attribute,NUS服务本身就是一个attribute,而RX characteristic本身又包含2条attribute:一条是declaration attribute,一条是value本身attribute。由于TX支持notify,所以它包含3条attribute,另外一条attribute是CCCD。每个attribute都有一个handle和UUID,handle用来访问该attribute,UUID用来指明该attribute的类型。可以说,server提供数据,而数据是由attribute来表达,所有attribute组成一个attribute table,设备支持的服务不同,attribute table就不同。这里说明一下,当你在Nordic已有例程基础上再去添加新的服务或者删除已有的服务,记得一定要去修改ATTR_TAB_SIZE那个宏,否则协议栈初始化会有问题。
5. 常用ATT命令
Client和Server之间是通过ATT PDU来通信的,ATT PDU主要包括4类:读,写,notify和indicate。如果一个命令需要response,那么会在相应命令后面加上request;如果一个命令只需要ACK而不需要response,那么它的后面就不会带request。这里要特别强调一点,BLE所有命令都是“必达”的,也就是说每个命令发出去之后,会立马等ACK信息,如果收到了ACK包,发起方认为命令完成;否则发起方会一直重传该命令直到超时导致BLE连接断开。换句话说,只要你的BLE没有断开,那么你之前发送的数据包,不管它是用什么ATT PDU来发送的,它肯定被对方收到了。我估计很多人对此会产生疑问,因为他们经常碰到丢包的情况,其实大家经常碰到的“丢包”,不是空中把包丢了或者包在空中被干扰了,而是大家发送的代码写得有问题,导致你要发送的包没有被安全送达到协议栈射频FIFO中,所以以后大家碰到丢包情况,请先检查你的代码,保证你的数据包正确完整安全地送达到协议栈射频FIFO中,只要数据包放到了协议栈射频FIFO中,蓝牙协议栈就能保证该数据包“必达”对方。既然每个ATT命令都必达对方,那么还需要request做什么?如果一个命令带有request后缀,那么发起方就可以收到命令的response包,这个response包在应用层是有回调事件的,而前述的ACK包在应用层是没有回调事件的。所以采用request/response方式,应用层可以按顺序地发送一些数据包,这个在很多应用场合是非常有用的。相反,如果你对应用层数据包的顺序没有要求,那么就可以不使用request/response形式。另外Request/response有一个副作用:大大降低通信的吞吐率,因为request/response必须在不同的连接间隔中出现,也就是说,你在间隔1中发送了一个request命令,那么response包必须在间隔2或者稍后间隔中回复,而不能在间隔1中回复,这就导致两个连接间隔最多只能发一个数据包,而不带request后缀的ATT命令就没有这个问题,在同一个连接间隔中,你可以同时发多个数据包,这样将大大提高数据的吞吐率。大家可以参考下图来理解request和非request命令的区别:
常用的带request的命令:所有read命令,write request,indication等,而常用的不带request的命令有write command,notification等,完整的ATT命令列表如下所示:
6. 设备端固件代码一览
现在我们一起来看一下ble_app_uart的源代码,看看它是怎么工作起来的。首先我们来看main函数:
如上所述,ble_stack_init用于初始化配置和使能蓝牙协议栈,其代码如下所示:
其中,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作为低频时钟,那么需要做如下修改:
按 Ctrl+C 复制代码
按 Ctrl+C 复制代码
nrf_sdh_ble_default_cfg_set用来配置softdevice协议栈,如下宏是经常需要修改的:
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 size为多大
NRF_SDH_BLE_VS_UUID_COUNT //用户自定义的base UUID有几个
NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE //Attribute table总共占多少协议栈RAM空间
NRF_SDH_BLE_SERVICE_CHANGED //要不要包含service change characteristic
nrf_sdh_ble_enable真正使能BLE功能,它的参数ram_start既是一个输入参数又是一个输出参数,作为输入参数,系统自动会把如下的RAM起始地址传入:
同时nrf_sdh_ble_enable会把当前softdevice配置情况下,它实际需要占用的RAM空间通过ram_start返回,如果这个返回值不等于输入值,那么用户需要把上图的IRAM1起始地址修改成它的返回值。其中NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE这个宏的取值是需要用户不断去试错的,因此每当你添加了或者删除了BLE service,都需要去调整NRF_SDH_BLE_GATTS_ATTR_TAB_SIZE这个宏的值,然后去查看nrf_sdh_ble_enable的返回值,看看这个参数的取值是否合理。
NRF_SDH_BLE_OBSERVER用来为本地文件(此处为main.c)注册一个BLE回调函数(此处为ble_evt_handler),NRF_SDH_BLE_OBSERVER这个宏执行成功后,所有的BLE事件都会被ble_evt_handler捕获。进入ble_evt_handler,你会发现BLE有上百个回调事件,你不需要每个都处理,你只需要处理你关心的事件即可,比如连接成功事件BLE_GAP_EVT_CONNECTED或者连接断开事件
BLE_GAP_EVT_DISCONNECTED,如下所示:
NRF_SDH_BLE_OBSERVER有一个很大的好处:某个模块如果需要捕获BLE事件,那么它自己调用NRF_SDH_BLE_OBSERVER这个宏注册相应回调函数即可,而不再需要在其它文件中去注册这个回调函数,将模块的耦合性降到最低,符合模块化编程思想。
gap_params_init用来修改广播名字和连接间隔的。gatt_init用来修改底层数据包长度的。advertising_init用来修改广播包内容,广播间隔以及广播超时时间。conn_params_init用来请求更新连接间隔的。
我们来重点讲一下services_init,services_init用来添加服务和characteristic,前面讲了那么多的概念和理论,现在我们就来看看services_init是如何做到跟理论一致的。services_init通过ble_nus_init添加了一个蓝牙数据透传服务:NUS,那ble_nus_init是怎么将NUS服务添加成功的呢?查看ble_nus_init函数体,你会发现它是分三步来做的:
- 添加服务的UUID。如果是蓝牙标准服务,这步可以省略。由于NUS不是蓝牙联盟定义的,所以需要调用sd_ble_uuid_vs_add以增加一个供应商自定义的UUID。
- 添加服务本身。直接调用sd_ble_gatts_service_add就可以完成。
- 添加服务下面的characteristics。server的characteristic一般都是通过sd_ble_gatts_characteristic_add来添加的。以NUS的RX characteristic为例,可以看到:
sd_ble_gatts_characteristic_add(p_nus->service_handle, &char_md, &attr_char_value, &p_nus->rx_handles);
其中,p_nus->service_handle表示该characteristic属于那个service,p_nus->rx_handles是输出值,由协议栈返回,以后访问该characteristic都是通过这个句柄来完成,attr_char_value这个是characteristic的value,char_md这个是characteristic的元数据(metadata),前面第4章也讲过,一个数据除了有value这个characteristic之外,它还包含其他attribute,而这些attribute全部都用char_md来表示,比如这个characteristic value能支持的ATT命令类型,CCCD信息,descriptor信息等,这里要特别指出的是,只有当支持notify或者indicate时,才需要提供cccd_md信息,其他ATT命令不需要cccd_md信息,所以RX characteristic的char_md如下所示,它同时支持write和write request两种写命令,由于它不支持notify或者indicate,所以cccd_md为NULL。
attr_char_value是一个attribute,所以它包含attribute metadata,如下:
attr_char_value具体包含的value信息由以下成员表示:
由于这里把characteristic value放在了协议栈RAM中,所以协议栈会自动为这个value创建一个buffer。如果你想把characteristic value放在用户RAM中,即vloc = BLE_GATTS_VLOC_USER,那么这里你还需要把一个全局数组变量赋给attr_char_value. p_value。
TX characteristic与之类似,就不再额外解读了。
这里需要特别提醒大家的是,虽然Nordic API结构体参数设计得很复杂,但是大部分成员变量直接就可以使用它的默认值0,你只需对你感兴趣的成员变量进行赋值即可,所以大家经常看到如下场合,即先用memset将该结构体变量初始化为0,让其所有成员变量都采用默认值,然后再对某些需要修改的成员变量进行二次赋值。大家一定不要忘了将结构体变量清零这一步操作!
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)))
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状态),其余时间系统都是处于idle状态的,从而大大节省系统功耗。
7. 定制你的BLE数据透传应用程序
7.1 BLE数据上传吞吐率
如何快速的把大量数据上传给手机?这是一个很常见的应用场合,现在我们尝试去修改一下Nordic的原生例程,以实现最高的数据吞吐率。下面我们通过几种不同的方法来看看每种方法下它的吞吐率能到多少。
方法1:(通过宏METHOD1来开关)
蓝牙spec规定,蓝牙连接间隔最小只能为7.5m,为了达到最高的吞吐率,我们创建一个timer,让其每7ms发一次数据,看一看此时吞吐率能达到多少。7ms中断服务函数代码如下所示:
static void throughput_timer_handler(void * p_context)
{
UNUSED_PARAMETER(p_context);
ret_code_t err_code;
uint16_t length;
m_cnt_7ms++;
length = m_ble_nus_max_data_len;
if (m_conn_handle != BLE_CONN_HANDLE_INVALID)
{
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);
// }
m_len_sent += length;
m_data_array[0]++;
m_data_array[length-1]++;
}
NRF_LOG_INFO("time: %d *7ms == bytes send: %d Bytes == avg speed: %d B/s",m_cnt_7ms,m_len_sent,m_len_sent/(m_cnt_7ms*7));
}
这种做法会导致ble_nus_data_send报“NRF_ERROR_RESOURCES”错误,这个错误表示协议栈无资源应付这么快的调用速度。为此我们对ble_nus_data_send返回的错误值一概不进行处理,看看会发生什么?我们发现程序可以正常运行,RTT viewer打印的日志如下所示:
由上图可知,数据上传吞吐率达到了34.8kB/s,其实这个吞吐率是假的,因为中间丢了很多包,但计算吞吐率的时候把丢的包也算进去了。如下图所示,0x6E之后应该为0x6F,但实际发送的数据包编号为0x83,丢包非常严重。
为了防止所谓的“丢包”(前面也提过,这里的丢包不是数据包在空中丢掉了,而是数据包没有安全送到协议栈的buffer中,从而导致丢包),我们加上如下if语句,只有ble_nus_data_send返回正确时,才认为数据包正确发送,然后才能算入到throughput中:
if (err_code == NRF_SUCCESS)
{
m_len_sent += length;
m_data_array[0]++;
m_data_array[length-1]++;
}
通过查看nRF connect日志,你会发现此时不会发生丢包了,但吞吐率直接降到了1.6kB/s左右。
方法1+:(通过宏METHOD1_PLUS来开关)
我们对方法一稍作调整,首先我们持续往发送buffer写数据,直到返回值不是NRF_SUCCESS
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);
然后我们把连接间隔设为尽可能小,以期提高吞吐率,如下:
#ifdef CONN_INTERVAL_OPTIMIZE
#define MIN_CONN_INTERVAL MSEC_TO_UNITS(8, UNIT_1_25_MS)
#define MAX_CONN_INTERVAL MSEC_TO_UNITS(12, UNIT_1_25_MS)
#endif
这种方法吞吐率能达到10kB/s,但离我们的目标还是很远。
最后我们把connection event length extension和data length extension都打开(我们将在方法2+中详细阐述这2个有效提高吞吐率的利器),即定义如下宏:
可以看到吞吐率将达到70kB/s,这个吞吐率还是不错的。但仔细查看nRF connect日志,你会发现这种模式下还是有小概率事件会导致“丢包”发生,而且整个发送逻辑也不是很优化,为此我们想到了METHOD2.
方法2:(通过宏METHOD2来开关)
ble_nus_data_send每次成功发送数据包,都会产生一个BLE_NUS_EVT_TX_RDY事件,收到这个事件后,再去调用ble_nus_data_send,丢包的情况就不会再发生了,核心代码如下所示:
if (p_evt->type == BLE_NUS_EVT_TX_RDY)
{
#ifdef METHOD2
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]++;
}
NRF_LOG_INFO("time: %d *10ms == bytes send: %d Bytes == avg speed: %d B/s",m_cnt_10ms,m_len_sent,m_len_sent * 100/m_cnt_10ms);
#endif
}
大家可以自己去查看一下nRF Connect的数据log,这种方式是没有丢包的,但是打开RTT viewer,你会发现他的吞吐率低得可怜,只有1kB/s。
方法2+:(通过宏METHOD2_PLUS来开关)
与方法1+类似,我们在方法2基础上,持续往发送buffer送数据直到返回值不为0,如下:
#ifdef METHOD2_PLUS
//queue multiple tx array
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);
NRF_LOG_INFO("time: %d *10ms == bytes send: %d Bytes == avg speed: %d B/s",m_cnt_10ms,m_len_sent,m_len_sent * 100/m_cnt_10ms);
#endif
然后我们增加gap event length extension功能,gap event length跟connection event length两者意思差不多,都是为了实现一个连接间隔可以发或收多个包的目的。为了使能gap event length extension功能,首先将gap event length修改成一个合适的值,以使其尽可能占满整个连接间隔,如下将gap event length修改为30ms
#define NRF_SDH_BLE_GAP_EVENT_LENGTH 24
然后我们再将连接间隔设为尽可能小,以保证上述connection event可以占据整个连接间隔:
#ifdef CONN_INTERVAL_OPTIMIZE
#define MIN_CONN_INTERVAL MSEC_TO_UNITS(8, UNIT_1_25_MS)
#define MAX_CONN_INTERVAL MSEC_TO_UNITS(12, UNIT_1_25_MS)
#endif
同时我们使能connection event extension功能,如下:
#ifdef EVT_LEN_EXT_ON
ble_opt_t opt;
memset(&opt, 0x00, sizeof(opt));
opt.common_opt.conn_evt_ext.enable = true;
err_code = sd_ble_opt_set(BLE_COMMON_OPT_CONN_EVT_EXT, &opt);
APP_ERROR_CHECK(err_code);
#endif
我现在使用的是华为P9手机,它将把MTU设为241,在DLE不开的情况下(此时链路层每个数据包的长度还是只有27个字节!),我们可以看到throughput可以达到10kB以上,如下:
然后我们再打开DLE功能,此时链路层每个数据包的长度将变成251字节,如下:
#ifdef DLE_ON
case BLE_GAP_EVT_DATA_LENGTH_UPDATE_REQUEST:
{
NRF_LOG_DEBUG("DLE update request.");
ble_gap_data_length_params_t dle_param;
memset(&dle_param, 0, sizeof(ble_gap_data_length_params_t)); //0 means auto select DLE
err_code = sd_ble_gap_data_length_update(p_ble_evt->evt.gap_evt.conn_handle, &dle_param, NULL);
APP_ERROR_CHECK(err_code);
} break;
#endif
此时我们可以看到throughput可以达到77kB/s,离蓝牙4.2的理论throughput已经很接近了。这里特别需要指出的是,当DLE使能情况下,connection interval不是越小吞吐率越高,我这里使用的connection interval大概为10ms,如果大家把这个connection interval提高到30ms,有可能吞吐率更高,这里就不再演示了。
上述代码工程已经上传到百度云盘中,有需要的同学可以到如下链接下载:
下载“tutorial_ble_app_uart_SDK15_0_0.rar”,然后解压缩到SDK15.0.0如下目录下:nRF5_SDK_15.0.0_a53641a\examples\ble_peripheral,即可成功编译运行。
7.2使用安卓版nRF connect测试BLE设备的稳定性和可靠性
先说明一下,以下内容只能通过安卓版nRF Connect来实现,iOS版nRF Connect不支持如下特性。
手机端宏录制方式
相信到现在大家对BLE数据上传机理和实践有个大概的了解,那如何测试BLE数据下行性能,即怎么测试数据从手机传到设备的稳定性和可靠性?我们是不是必须开发一款手机app来进行相关测试吗?答案是否定的,感谢Nordic给我们带来了nRF connect,nRF connect支持宏录制,我们可以通过nRF connect来对我们的设备进行压力测试。下面我们来讲讲宏录制是怎么工作的。
所谓宏录制,就是把你对nRF connect的操作录制下来,然后通过宏播放实现自动化操作。由于nRF connect是一个容器,并支持JavaScript和HTML语法,宏其实就是一个XML脚本,nRF connect定义了自己的一套XML标签操作,遵守这套XML标签操作,就可以对nRF connect进行自动化操作。nRF connect支持的所有XML语法都在手机安装目录\Nordic Semiconductor中的示例中体现,只要示例中出现过的标签就支持,相反示例中没有的标签就不支持。下面具体讲一下宏录制的操作过程。
当nRF connect连接设备成功后,你会发现右下角有一个红点,那个就是宏录制菜单。
点击下面的红点,我们开始宏录制操作
然后我们按照普通操作来操作nRF connect,这些操作最终对应的BLE指令会被录制下来,以便后续重复播放。我们先把“1234”发送给设备,如下:
发送完上述指令后,我们加一个300ms的延时,如下:
然后我们点击完成按钮,保存该宏,可以看出这个宏包括两条操作:发送“1234”到设备,然后睡眠300ms。
将宏命名为“test”并保存:
到此宏已经录制成功了,现在我们开始展示宏的神奇功能。如下,选择循环播放模式,然后点击“开始”按钮开始循环播放该录制宏.
大家可以看到,nRF connect先执行“Write 0x31323334 to RX characteristic”,然后睡眠300ms,然后又执行“Write 0x31323334 to RX characteristic”,如此循环往复。打开串口助手,你会发现设备已经收到了手机发过来的一连串“1234”,如下。
我们把刚才的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
电脑端XML方式
前面的宏录制方式,功能还是比较单一,如果要实现更复杂的自动化测试,可以通过在PC端执行XML脚本方式来实现。通过安卓调试工具ADB,我们可以直接通过PC来操作nRF connect,而nRF connect又能识别XML脚本,这样就可以让nRF connect按照XML脚本意图去执行相关自动化操作。nRF connect支持的所有XML语法都在手机安装目录中(手机内部存储/ Nordic Semiconductor目录)的示例中体现,只要示例中出现过的标签就支持,相反示例中没有的标签就不支持。
8. 开发手机端app代码
Nordic提供很多手机端开源app供大家参考,用得最多的就是nRF Toolbox和nRF Blinky(注:nRF connect代码不开源),在nRF Toolbox和nRF Blinky中都有相关的BLE操作库,尤其是nRF Toolbox包含了很多BLE库,比如BLE管理,DFU,数据透传,蓝牙Mesh等等,大家可以参考他们来开发自己的手机端app。
- Android版nRF Toolbox源代码及开发说明请参考:https://github.com/NordicSemiconductor/Android-nRF-Toolbox
- iOS版nRF Toolbox源代码及开发说明请参考:https://github.com/NordicSemiconductor/IOS-nRF-Toolbox
nRF Toolbox软件界面如下所示:
UART就是前文说到的NUS服务,除了nRF connect,其实大家也可以通过nRF Toolbox UART模块来完成第2章所述的操作。nRF Toolbox另一个用的比较多的功能就是DFU,如果你需要通过手机BLE来实现设备固件的空中升级(OTA),那么可以参考nRF Toolbox DFU模块来编写你的手机端软件。