本文为经典蓝牙通讯过程简单梳理。
1.实用工具
image.pngsscom可以调试收发数据。当蓝牙设备连接到PC端,确保相关驱动安装之后,PC能识别蓝牙设备,然后选择适当的波特率,一般是9600,选中端口号后打开串口,就可以发送数据了,也能显示接收的数据。
开发过程中,可以使用这个工具测试一些数据收发问题。
2.蓝牙通讯基本流程
参考这篇即可:
Android蓝牙串口通信
然后还有一些定位权限问题,socket异常处理,需要额外添加。
基本流程如下。
3.数据处理
蓝牙SPP的协议栈使用是rfcomm(串口仿真协议),通过一个虚拟串口通道和对方通信。
蓝牙协议栈
这里蓝牙设备与Android设备采用异步串行接口(UART)进行数据通信。UART是异步串口通信协议的一种,数据传输一般都是以字节传输的,一个字节8个位,将传输数据一位接一位地传输,要传一个字节就需要传8次。
3.1粘包和半包
粘包:
指数据在传输时,在一条消息中读取到了另一条消息的部分数据,这种现象就叫做粘包。
比如发送了两条消息,分别为“ABC”和“DEF”,那么正常情况下接收端也应该收到两条消息“ABC”和“DEF”,但接收端却收到的是“ABCD”,像这种情况就叫做粘包,如下图所示:
半包:
是指接收端只收到了部分数据,而非完整的数据的情况就叫做半包。比如发送了一条消息是“ABC”,而接收端却收到的是“AB”和“C”两条信息,这种情况就叫做半包,如下图所示:
image.png
产生原因
粘包的主要原因:
- 发送方每次写入数据 < 套接字(Socket)缓冲区大小
- 接收方读取套接字(Socket)缓冲区数据不够及时
半包的主要原因:
- 发送方每次写入数据 > 套接字(Socket)缓冲区大小
- 发送的数据大于协议的 MTU (Maximum Transmission Unit,最大传输单元),因此必须拆包
其实我们可以换个角度看待问题:
- 从收发的角度看,便是一个发送可能被多次接收,多个发送可能被一次接收。
- 从传输的角度看,便是一个发送可能占用多个传输包,多个发送可能共用一个传输包。
根本原因,其实是TCP 是流式协议,消息无边界。
(PS : UDP 虽然也可以一次传输多个包或者多次传输一个包,但每个消息都是有边界的,因此不会有粘包和半包问题。)
粘包和半包问题是数据传输中比较常见的问题。
它的解决方案有很多,比较常见的解决方案有:
设置固定的数据传输大小、自定义请求协议的封装,在请求头中加入传输数据的长度、使用特殊符号作为结束符等。
3.2 发送数据处理
为确保数据的完整性(减少错误),数据按照固定的帧格式进行传输。
3.2.1 CRC校验
验证CRC 计算的网站:http://www.ip33.com/crc.html
输入要传入的数据,然后选择参数模型即可,例如CRC-16/Modbus。
B与bit
数据存储是以“字节”(Byte)为单位,数据传输大多是以“位”(bit,又名“比特”)为单位,一个位就代表一个0或1(即二进制),每8个位(bit,简写为b)组成一个字节(Byte,简写为B),是最小一级的信息单位
image.png
java不论是负数还是正数在定义、存储、计算的过程中,都是用其补码
public static String getCRC(byte[] bytes) {
// ModBus 通信协议的 CRC ( 冗余循环校验码含2个字节, 即 16 位二进制数。
// CRC 码由发送设备计算, 放置于所发送信息帧的尾部。
// 接收信息设备再重新计算所接收信息 (除 CRC 之外的部分)的 CRC,
// 比较计算得到的 CRC 是否与接收到CRC相符, 如果两者不相符, 则认为数据出错。
//
// 1) 预置 1 个 16 位的寄存器为十六进制FFFF(即全为 1) , 称此寄存器为 CRC寄存器。
// 2) 把第一个 8 位二进制数据 (通信信息帧的第一个字节) 与 16 位的 CRC寄存器的低 8 位相异或, 把结果放于 CRC寄存器。
// 3) 把 CRC 寄存器的内容右移一位( 朝低位)用 0 填补最高位, 并检查右移后的移出位。
// 4) 如果移出位为 0, 重复第 3 步 ( 再次右移一位); 如果移出位为 1, CRC 寄存器与多项式A001 ( 1010 0000 0000 0001) 进行异或。
// 5) 重复步骤 3 和步骤 4, 直到右移 8 次,这样整个8位数据全部进行了处理。
// 6) 重复步骤 2 到步骤 5, 进行通信信息帧下一个字节的处理。
// 7) 将该通信信息帧所有字节按上述步骤计算完成后,得到的16位CRC寄存器的高、低字节进行交换。
// 8) 最后得到的 CRC寄存器内容即为 CRC码。
int CRC = 0x0000ffff;
int POLYNOMIAL = 0x0000a001;
int i, j;
for (i = 0; i < bytes.length; i++) {
CRC ^= (int) bytes[i];
for (j = 0; j < 8; j++) {
if ((CRC & 0x00000001) == 1) {
CRC >>= 1;
CRC ^= POLYNOMIAL;
} else {
CRC >>= 1;
}
}
}
//高低位转换,看情况使用(譬如本人这次对led彩屏的通讯开发就规定校验码高位在前低位在后,也就不需要转换高低位)
//CRC = ( (CRC & 0x0000FF00) >> 8) | ( (CRC & 0x000000FF ) << 8);
Log.d(TAG,"int CRC = " + CRC + " , string src = " + Integer.toHexString(CRC));
return Integer.toHexString(CRC);
}
如果协议要求16位CRC高8位在后,低8位在前,就需要使用高低位转换。
3.2.2 数据转换
串口传输接口底层是按位(bit)发送的,上层是按byte发送和接收的,但协议为了方便描述,每个byte用十六进制数(0x00~0xFF)表示,范相当于十进制的0~255,而byte为八位且是有符号类型,相当于十进制的-128~127,明显0x8F0xFF(128~255)是不能准确转换为byte的,咋办?因为0xFF被当作255整数处理,255超过了byte(-128127)的范围,不能直接赋值,只能强转啊。
byte b = (byte) 0xFF;
但此时你会不会觉得这样写就和协议想发送0xFF的想法不匹配了呢,我明明想发个255来着,那我告诉你,你一个一个字节的发,每个字节就只能是-128~127,不能在其他范围,所以强转的目的是你可以发送这个0xFF的前提,发送的值肯定不是255,具体参考下面的例子:
public class Main {
public static void main(String[] args) {
byte c1 = (byte) 0xFF;
int c2 = c1 & 0xFF;
System.out.println(""+c1);
System.out.println(""+c2);
}
}// -1 255
可见,实际上是一个-1,但接受端为了表达这是个发送端发送给我的是0xFF(255)而不是-1,你可以通过取到该字节值后 “ 该值 & 0xFF”转为无符号值即255.
3.2.3 组帧
现存在一个byte数组,如下,发送命令前需按CRC-16/Modbus计算CRC,16位CRC高8位在后,低8位在前。
private byte[] bytes= {0x5A, (byte) 0xA5,0x01 ,0x06 ,0x00 ,0x01, 0x00 ,0x17};
...
getCRC(bytes);
可得到数据int CRC = 23976 , string src = 5da8
返回 5da8 。
CRC-16/Modbus是16位CRC高8位在后,低8位在前。
所以实际需要发送的数据 是
A8 5D
那么getCRC
就需要修改一下。使用CRC = ( (CRC & 0x0000FF00) >> 8) | ( (CRC & 0x000000FF ) << 8);
进行高低位转换。
如果需要返回byte[]数据的话,就new byte。
return new byte[]
{(byte) (CRC & 0xff), (byte) (CRC >> 8 & 0xff);};
然后再使用 System.arraycopy拼接。
System.arraycopy(buf, 0, out, 5, len);
arraycopy(Object src, int srcPos, Object dest, int destPos, int length)
- src:源数组;
- srcPos:源数组要复制的起始位置;
- dest:目的数组;
- destPos:目的数组放置的起始位置;
- length:复制的长度.
5个参数 - 第一个参数是要被复制的数组
- 第二个参数是被复制的数字开始复制的下标
- 第三个参数是目标数组,也就是要把数据放进来的数组
- 第四个参数是从目标数据第几个下标开始放入数据
- 第五个参数表示从被复制的数组中拿几个数值放到目标数组中
3.3 处理接收数据
简单举例。
指令格式
格式说明:
字段说明STX起始符(固定为0x80,1 byte)LEN数据长度(CMD、DATA的长度,2 bytes)CMD业务类型(1 byte)DATA业务参数(TLV格式,可变长度)ETX结束符(固定为0x81,1 byte)CRCCRC32校验(取高位两个字节,2 bytes)
处理逻辑:将每次串口收到的数据追加保存到全局的ByteBuffer中,并每次对ByteBuffer中实际数据长度与字段LEN计算的长度进行比较,截取一条完整有效的指令处理。
public class SerialPortUtils {
/**
* 串口数据
*/
private ByteBuffer byteBuffer;
public void receiveSerialMsg(String msg) {
try {
byte[] receiveBytes = hexStr2bytes(msg);
// 将串口收到的数据追加到ByteBuffer
byteBuffer.put(receiveBytes);
if(byteBuffer.position() >= 3) {
byte[] byteLength = new byte[2];
byteLength[0] = byteBuffer.get(1);
byteLength[1] = byteBuffer.get(2);
// 计算一条完整指令的长度
// 6表示STX(1 byte) + ETX(1 byte) + LEN(2 bytes) + CRC(2 bytes)
int realLen = (byteLength[0] << 8) + byteLength[1] + 6;
//byteBuffer实际长度与LEN字段长度比较,若相等则表示已经接收到一条完整的指令
if(realLen == byteBuffer.position()) {
byte[] effectiveBytes = new byte[realLen];
byteBuffer.flip();
byteBuffer.get(effectiveBytes, 0, realLen);
byteBuffer.clear();
dealEffectiveBytes(effectiveBytes);
} else if(realLen < byteBuffer.position()) {
//粘包,截取有效数据,当一次收到多条完整指令时,根据业务场景决定多条指令的解析
int remainLen = byteBuffer.position() - realLen;
byte[] effectiveBytes = new byte[realLen];
byte[] remainBytes = new byte[remainLen];
byteBuffer.flip();
byteBuffer.get(effectiveBytes, 0, realLen);
byteBuffer.get(remainBytes, 0, remainLen); //剩余字节
byteBuffer.clear();
byteBuffer.put(remainBytes); //回填剩余字节,下一次收到数据继续追加接
// 根据业务场景,一次性收到多条完整指令时,只处理第一条指令,其余的舍弃
dealEffectiveBytes(effectiveBytes);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 解析一条完整的指令
private void dealEffectiveBytes(byte[] effectiveBytes) {
Map<String, String> outputMap = new HashMap<>();
//将指令解析结果存到Map中
int result = dataParser(effectiveBytes, outputMap);
}
public byte[] hexStr2bytes(String hex) {
int len = (hex.length() / 2);
byte[] result = new byte[len];
char[] achar = hex.toUpperCase().toCharArray();
for (int i = 0; i < len; i++) {
int pos = i * 2;
result[i] = (byte) (hexChar2byte(achar[pos]) << 4 | hexChar2byte(achar[pos + 1]));
}
return result;
}
//指令中业务参数解析
public int dataParser(byte[] data, Map<String, String> outputMap) {
// 校验STX、ETX
if (data[0] != (byte) 0x80 || data[data.length - 3] != (byte) 0x81) {
return ERROR_STX_ETX;
}
// 校验CRC
if (!checkCRC(data)) {
return ERROR_CRC;
}
int index, curLen = 0;
int dataLen = (data[1] << 8) + data[2];
// 校验长度
if ((dataLen + 6) != data.length) {
return ERROR_LENGTH;
}
byte[] temp = new byte[1];
//解析CMD
temp[0] = data[3];
String strCmd = byte2hex(temp);
outputMap.put("SERIAL_PORT_CMD", strCmd.toUpperCase());
if (data[3] == (byte) 0xA0) { //自定义的CMD
index = 4;
while (curLen < (dataLen - 1)) {//解析DATA,TLV格式
byte type = data[index++];
int length = data[index++];
byte[] value = new byte[length];
for (int i = 0; i < length; i++) {
value[i] = data[index++];
}
if (type == (byte) 0x01) { // 参数:时间
outputMap.put("SERIAL_PORT_TIME", new String(value));
} else if(type == (byte) 0x02) { // 参数:握手数据
outputMap.put("SERIAL_PORT_TEST", new String(value));
}
curLen += length + 2;
}
}
return 0;
}
}
receiveSerialMsg方法,在线程中循环接收到串口数据后调用。
基本逻辑就是 根据数据格式(帧头和帧尾)去取有效数据,过滤无效数据。然后有时候,有效数据中夹杂有一定规律的无效数据,就需要根据具体业务逻辑,用正则去匹配,删掉这些数据。
参考链接:
android串口通信接受自定义协议数据并解析问题
Android蓝牙串口通信
比isConnected()更靠谱的的获取socket实时连接状态!
Android经典蓝牙开发全流程
Android蓝牙开发—经典蓝牙详细开发流程
[蓝牙] 1、蓝牙核心技术了解(蓝牙协议、架构、硬件和软件笔记)
经典蓝牙那些事儿——(登录、登录异常、自动连接、中断、断后重连等)
关于蓝牙开发,必须注意的广播
Android串口通信,分包/黏包数据解析
java实现CRC16 MODBUS校验算法
java byte和int_Java里byte和int对比问题
socket的封包、粘包、解包,做一个即时通讯项目
SOCKET/串口通信粘包问题处理,附带详细代码
Android串口通信,分包/黏包数据解析
【粘包和拆包】数据帧粘包和拆包处理方式
串口通讯(含协议)支持粘包分包解析数据
tcp粘包和拆包的处理方案
android串口通信接受自定义协议数据并解析问题
System.arraycopy的使用方法详解
Modbus 通信协议详解
网友评论