在使用带BLE功能的MCU进行应用开发的时候,需要先熟悉BLE的API. 然而,各厂家的BLE API风格差异很大,要比不同器件平台硬件驱动库HAL之间的差别更大。底层无线电部分的硬件,各家自有独立的设计(硬件寄存器也不一定开放),况且BLE协议栈有很大一部分是软件实现,它不光涉及无线电部分,还需要定时器和中断管理、电源管理,甚至用到动态内存分配。于是要用BLE通信,协议栈部分几十上百kB的代码占用是很常见的(有的平台把API实现放到ROM里能省部分),但难处在于不容易预测它的软件行为,如一个API调用的执行时间、什么时候会用回调函数、什么时候需要切换低功耗模式等等。每当接触一个新的BLE MCU平台时,对BLE API的学习时间要远多于GPIO、UART这些基础硬件。如果对BLE技术缺乏认识,学习这些API更容易一头雾水。
BLE协议栈包含的内容太多了,一下弄明白太难。作为MCU应用开发,又不一定需要了解那么多,只要能实现需要的数据通信就够了。跟手机用BLE通信会麻烦一点,但如果是MCU和MCU之间通信呢?用过NRF24L01吗?它的空中数据包和BLE的数据包很相似,因为协议简单了,没有BLE的Profile, Service那些概念,对MCU工程师友好很多。
BLE应用如果只做一个beacon的话,就是只管定期发出数据,不需要建立连接的那种,其实是用不着协议栈的,甚至可能BLE API都不用到——这么说是不是一下子简单了?比如,我只需要定时广播一个温度信息,真没必要那么复杂啊。理解了BLE的数据包,就可以用不复杂的办法来做。
这还有一个条件,就是能直接访问MCU上的无线电部分硬件:得有一个开放的硬件环境,有手册。本帖将用nRF51822来演示怎么直接操作硬件进行数据包的收发。nRF51822是比较老的BLE MCU了,很容易从拆机的手环类电路板上找到,它后一代的nRF52xxx系列性能更好,无线部分硬件变化不大。除了nRF51xxx之外,看手里的板子能不能直接操作无线电部分硬件,就查参考手册看对应有没有详细的寄存器描述。刚结束的RSL10大赛用的板子也是可以玩的。
下面是nRF51xxx手册中RADIO部分的硬件结构框图:
接收和发送部分大致是独立的,但不能同时工作,就是半双工的意思。要发送的数据包存放在RAM中,硬件通过DMA自动读取,然后会加上地址、CRC、同步头等,并经过whitening步骤,然后用GFSK调制发送出去。接收过程是类似的,硬件通过包头检测、地址匹配、CRC校验过程筛选合法的数据包,由DMA写到RAM中指定的地址。
nRF51822支持的数据包格式是这样的:
这和BLE spec中基础数据包格式是兼容的(不然怎么支持BLE),所以我们将它配置成BLE的格式,就可以直接收发数据了。
Preamble部分是0/1交错的同步码,0xAA或者0x55,取决于地址部分的LSB(最先发送的那一bit),硬件负责。
地址部分,nRF51822的地址长度可以是3~5字节,分被BASE和PREFIX两部分。BLE的Access Address是4字节,因此设置BASE长度为3字节。
接下来的S0、LENGTH、S1字段是可选的(长度可以设成0),如果用了,则需要看成BLE数据包的PDU的一部分。和后面的PAYLOAD部分一起组成PDU.
最后CRC部分由硬件负责,需要设置为24-bit, 要按照BLE要求设置。
先试验能否从空中捕捉到BLE的数据包。需要提供给RADIO硬件的参数还有:(1)信道,(2)地址,(3)包长度。关于信道,为了捕捉advertising类型的包,可以设置成37、38、39信道当中的一个。设成其它信道捕捉连接数据包,除了要根据跳频算法不断更改信道外,还需要知道Access Address才可以。BLE 37、38、39信道使用固定的Access Address: 0x8E89BED6, 但建立连接后用的Access Address是主设备随机生成的,在CONNECT_REQ包中提供给从设备。包长度在BLE包PDU的第2个字节,也就是把上面的S0字段长度设置为1字节后,LENGTH字段就可以对应BLE PDU长度。nRF51822的RADIO使用LENGTH字段的信息(接收时来自空中数据,发送时来自RAM数据)来决定收发数据长度,不然就只能采用固定长度了。
为了接收37信道(中心频率2402MHz)的advertising类型数据包,用这样的配置:
NRF_RADIO->RXADDRESSES = 1; // enable address 0
NRF_RADIO->FREQUENCY = 2; // 2402MHz, CH37
NRF_RADIO->DATAWHITEIV = 37;
NRF_RADIO->MODE = (RADIO_MODE_MODE_Ble_1Mbit << RADIO_MODE_MODE_Pos);
NRF_RADIO->PREFIX0 = 0x8E;
NRF_RADIO->BASE0 = 0x89BED600;
// LFLEN=6 bits, S0LEN=1Byte, S1LEN=2bit
NRF_RADIO->PCNF0 = 0x00020106;
// STATLEN=6, MAXLEN=37, BALEN=3, ENDIAN=0 (little), WHITEEN=1
NRF_RADIO->PCNF1 = 0x02030025;
NRF_RADIO->CRCCNF = 0x103; // only PDU, 3 octets
NRF_RADIO->CRCINIT = 0x555555; // for advertising packet
NRF_RADIO->CRCPOLY = 0x100065b;
// set receive buffer
NRF_RADIO->PACKETPTR = (uint32_t)pkt_buf;
启动接收过程或发送过程要通过nRF51的task型寄存器。先看下RADIO部分的状态转移图:
在DISABLED状态通过TXEN或RXEN task启动硬件,到TXIDLE或RXDILE的准备状态,然后用START来进行一次传输。从接收切换到发送,以及从发送切换到接收,必须先转回DISABLED状态。
在接收状态下,硬件会监听指定地址的数据包,接收完成后转到RXIDLE状态,并产生END event.
当收到END event时,表示收到了一个数据包(地址匹配有效),然后可以访问CRCSTATUS寄存器判断CRC校验是否正确。若CRC有错,可能是数据包被干扰破坏,或者格式不正确。接收数据包的S0、LENGTH、S1、PAYLOAD字段存放到RAM中,稍有变化的是LENGTH和S1字段都被扩展成了字节存储。
我写了一个循环来持续接收数据包,进行37信道的监听。使用双缓冲区轮流存放收到的数据包,以便一边解析数据一边接收。
for(;;)
{
NRF_RADIO->PACKETPTR = (uint32_t)pkt_buf1;
NRF_RADIO->EVENTS_END = 0;
NRF_RADIO->TASKS_START = 1;
if(crcok2)
show_pkt(pkt_buf2);
else
uart_wstr(".");
if(NRF_RADIO->EVENTS_END)
uart_wstr("!");
while(! NRF_RADIO->EVENTS_END)
{}
crcok1=NRF_RADIO->CRCSTATUS;
NRF_RADIO->CRCINIT = 0x555555; // for advertising packet
NRF_RADIO->PACKETPTR = (uint32_t)pkt_buf2;
NRF_RADIO->EVENTS_END = 0;
NRF_RADIO->TASKS_START = 1;
if(crcok1)
show_pkt(pkt_buf1);
else
uart_wstr(".");
if(NRF_RADIO->EVENTS_END)
uart_wstr("!");
while(! NRF_RADIO->EVENTS_END)
{}
crcok2=NRF_RADIO->CRCSTATUS;
}
通过对PDU第一个字节的低4位,可以判断数据包类型,然后识别余下数据。
static inline void show_pkt(volatile uint8_t *buf)
{
switch(buf[0]&0xF)
{
case 6: // ADV_SCAN_IND
uart_wstr("s");
add_log(buf);
break;
case 0: // ADV_IND
uart_wstr("A");
add_log(buf);
break;
case 2: // ADV_NONCONN_IND
uart_wstr("n");
add_log(buf);
break;
case 4: // SCAN_RESP
uart_wstr("R");
break;
case 1: // ADV_DIRECT_IND
uart_wstr("i");
break;
case 3: // SCAN_REQ
uart_wstr("+");
break;
case 5: // CONN_REQ
uart_wstr("C");
break;
default:
uart_wstr("?");
break;
}
}
如果是包含advertising数据的包,可以将地址、数据记录下来,待收集一段时间后进行统计。
void add_log(uint8_t *buf)
{
int i;
for(i=0;i<32;i++)
{
if(adv_log[i].count) // not blank
{
if(memcmp(adv_log[i].addr, buf+3, 6)==0 && adv_log[i].type==buf[0]) // match
{
adv_log[i].count++;
return;
}
}
else // add entry
{
memcpy(adv_log[i].addr, buf+3, 6);
adv_log[i].type = buf[0];
adv_log[i].len = buf[1]-6;
memcpy(adv_log[i].payload, buf+9, adv_log[i].len);
adv_log[i].count=1;
return;
}
}
}
这样就可以发现周围的一部分BLE设备了。现在我的程序只是接收,没有主动发起“扫描”。但是我的程序收到了许多主动扫描的包,表明附近有设备在持续进行疯狂扫描……
以上演示的是单向接收。单向发送也是容易实现的,只要填充一个advertising包,把要发送的数据包含在内,用TX模式发送出去就是了。发送的设置和接收基本一样。
void radio_adv_tx(uint8_t *pdu, uint8_t len)
{
uint8_t txpkt[40];
NRF_RADIO->EVENTS_READY = 0;
NRF_RADIO->TASKS_TXEN = 1;
while (NRF_RADIO->EVENTS_READY == 0);
// now in TXIDLE state
txpkt[0]=0x42; // private TX address, non-connectable
if(len>31)
len=31;
txpkt[1]=len+6;
txpkt[2]=0;
txpkt[3]=0x37; txpkt[4]=0x5A; txpkt[5]=0x29;
txpkt[6]=0xC6; txpkt[7]=0x8B; txpkt[8]=0x04;
memcpy(txpkt+9, pdu, len);
NRF_RADIO->PACKETPTR = (uint32_t)txpkt;
NRF_RADIO->EVENTS_END = 0;
NRF_RADIO->TASKS_START = 1;
while(! NRF_RADIO->EVENTS_END)
{}
}
使用一个包含名称的advertising数据,调用上面的函数。设备地址是04:8B:C6:29:5A:37, 写在发送函数中了。
const uint8_t dummy_adv[]={0x02,0x01,0x06, // flags
15,0x09,'A','D','V','_','D','e','m','o',' ','5','1','8','2','2'};
radio_adv_tx(dummy_adv,sizeof(dummy_adv));
定期(比如1秒)发送一次,在手机上用BLE扫描工具可以发现这个设备。当然现在仅仅广播了一个名称而已,要添加自定的传感器数据也很简单,不过要注意31个字节长度的限制。
以上只是最初级的直接操作硬件进行BLE数据包收发的演示,只用了单向数据,因此简单了。如果要两个设备有应答地交互,就需要发送方在数据包发送之后切换到接收状态,等待一小段时间看是否有应答。BLE的连接建立起来后,主从双方的收发方向就是在不断地切换,如果要自己编程操作硬件实现这些,而不使用协议栈的API, 理论上是可以做的,问题在于有没有必要了。
利用BLE MCU的无线电硬件部分,做一些调试工具是可行而且有用的。还可以做自己的私有协议通讯,那样就不能再叫做BLE了。