美文网首页
蓝牙通讯简单总结

蓝牙通讯简单总结

作者: 梧叶已秋声 | 来源:发表于2022-09-04 09:46 被阅读0次

    本文为经典蓝牙通讯过程简单梳理。

    1.实用工具

    image.png

    sscom可以调试收发数据。当蓝牙设备连接到PC端,确保相关驱动安装之后,PC能识别蓝牙设备,然后选择适当的波特率,一般是9600,选中端口号后打开串口,就可以发送数据了,也能显示接收的数据。
    开发过程中,可以使用这个工具测试一些数据收发问题。

    2.蓝牙通讯基本流程

    参考这篇即可:
    Android蓝牙串口通信
    然后还有一些定位权限问题,socket异常处理,需要额外添加。
    基本流程如下。

    3.数据处理

    蓝牙SPP的协议栈使用是rfcomm(串口仿真协议),通过一个虚拟串口通道和对方通信。



    蓝牙协议栈

    这里蓝牙设备与Android设备采用异步串行接口(UART)进行数据通信。UART是异步串口通信协议的一种,数据传输一般都是以字节传输的,一个字节8个位,将传输数据一位接一位地传输,要传一个字节就需要传8次。

    3.1粘包和半包

    粘包:
    指数据在传输时,在一条消息中读取到了另一条消息的部分数据,这种现象就叫做粘包。
    比如发送了两条消息,分别为“ABC”和“DEF”,那么正常情况下接收端也应该收到两条消息“ABC”和“DEF”,但接收端却收到的是“ABCD”,像这种情况就叫做粘包,如下图所示:

    image.png
    半包:
    是指接收端只收到了部分数据,而非完整的数据的情况就叫做半包。比如发送了一条消息是“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
    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 。

    image.png

    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 通信协议详解

    相关文章

      网友评论

          本文标题:蓝牙通讯简单总结

          本文链接:https://www.haomeiwen.com/subject/bvevnrtx.html