在通信过程过程中,交互的基础单位是“字节”,即发送和接收的最小单位是“字节”。
而在多数通信协议中,我们定义的源码很可能是无符号的十六进制字符串。随后转为字节数组进行传输。
在这个过程中,可能涉及到字节数组转十六进制字符串,十六进制字符串转字节数组,字节转8位、16位、32位整数,字符串转字节数组、字节数组转字符串等等转换操作。
1、Java处理协议传输常见问题
1.1、有符号数和无符号数,大端序和小端序
1.1.1、有符号数和无符号数
有符号数和无符号数的差别在于:有符号数最高位为符号位。同样位数的类型无符号的byte取值范围是0~255,而有符号位的取值范围是 -128~127。
Java中所有的整数类型(byte、short、int、long)都以有符号数的形式存储(即用补码表示二进制数),其二进制形式的第一个二进制位为符号位:0表示正数,1表示负数。
- 补码:
- 正数的补码为其本身
- 负数补码为其绝对值各位取反加1
- 例如:
- +21,其二进制表示形式是00010101,则其补码同样为00010101。
- -21,按照概念其绝对值为00010101,各位取反为11101010,再加1为11101011,即-21的二进制表示形式为11101011。
- 而同样是11101011的无符号数为235
注意,==在协议传输中我们一般使用无符号数,而Java并未提供无符号类型==。
1.1.2、大端序和小端序
如果数据都是单字节的,其存储顺序就没有所谓,但是对于多字节数据,比如int,double等,就要考虑存储的顺序了。
- Big-endian(大端序):数据的高位字节存放在地址的低端,低位字节存放在地址高端。
- Little-endian(小端序):数据的高位字节存放在地址的高端,低位字节存放在地址低端。 不同的机器中,数据的存储方式可能会有差异,这就导致了在C系列的语言中,如果在不同的机器间进行通信,就需要对不同的“序”进行额外的处理。
所幸Java运行在JVM上,而JVM屏蔽了机器底层的端序差异,无需考虑跨平台的问题。在Java中,永远都是大端序。如果使用Java编写通信程序,就==无需考虑端序的问题==。
1.2、数字运算
1.2.1、有无符号数带来的问题
- 由于在Java中数字以有符号数存储,而在协议传输过程中,数字以无符号数存储。这就导致:
- 加减乘除等基于值的==四则运算==,==必须要保证值不丢失才能正确计算==。
- ==直接基于二进制位的位运算则不受影响== 比如:
public static void main(String[] args) { ByteBuf byteBuf = Unpooled.buffer(); byteBuf.writeByte(255); byteBuf.writeByte(133); byteBuf.writeByte(255); byteBuf.writeByte(133); // 读取两个一个字节的有符号数 byte b1 = byteBuf.readByte(); byte b2 = byteBuf.readByte(); // 读取两个一个字节的无符号数 short i1 = byteBuf.readUnsignedByte(); short i2 = byteBuf.readUnsignedByte(); // 虽然打印的结果不同,其底层的二进制数相同 System.out.println(b1); System.out.println(i1); // 如果进行位运算 // 虽然打印的结果不同,其底层的二进制数相同 System.out.println(b1&b2); System.out.println(i1&i2); // 但是如果进行四则运算 // 两种结果完全不同 System.out.println(b1+b2); System.out.println(i1+i2); }
- 在Java中处理无符号数:
- 位运算:无所谓有符号无符号,只要不对数字进行更改,直接进行运算即可。
- 四则运算:使用“高一级”的 类型替代。比如用short接收无符号的byte,用int 接收无符号short等等。
1.2.2、常见校验码计算方法
- 异或校验:从消息头开始,同后一字节异或,直到校验码前一个字节
- 示例代码:
/** * 对字节数组计算异或检验码,并在某位添加,占一个字节 * @param bytes */ private static void addCheckByte(List<Byte> bytes) { if (bytes.size()>=2){ //计算校验码 Iterator<Byte> iterator = bytes.iterator(); byte checkByte = iterator.next(); while (iterator.hasNext()){ checkByte ^= iterator.next(); } //添加到结尾 bytes.add(checkByte); } }
- 无进位累加和:
2、数字编码解码方法
2.1、Java原生的转化方法
- Java本身提供了一些转化方法,但是不太直观并且不方便使用,比如:
-
Java整数的包装类都提供了解析对应进制有符号数(即范围仍然是有符号数的范围)的方法,即
valueOf(String value,int radix
,其中第二个参数表示进制。但是注意,valueOf方法中,负数不再使用补码,而直接使用负号。例如:public static void main(String[] args) { String positiveBinaryValue = Integer.toBinaryString(255); //正数,正常输出255 System.out.println(Integer.valueOf(positiveBinaryValue,2)); String negativeBinaryValue = Integer.toBinaryString(-255); //负数,会报错,不能识别补码 //System.out.println(Integer.valueOf(negativeBinaryValue)); //添加负号实现负数,输出-255 System.out.println(Integer.valueOf("-"+positiveBinaryValue,2)); }
在多数通信协议中,我们都使用无符号数。
这就导致了直接使用Java包装类的valueOf方法不能很好地解析传输的内容。比如Byte.valueOf("ff",16),就会报
NumberFormatException: Value out of range
异常。
-
- 所以这里推荐使用Netty提供的转化方法。
2.2、Netty提供的转化方法
2.2.1、编码
- Netty的
ByteBuf
类提供了针对不同数据类型的一系列编码方法writeByte(int num)
,向流中写入一个字节writeShort(int num)
,向流中写入两个字节writeMedium(int num)
,向流中写入三个字节writeInt(int num)
,向流中写入四个字节writeLong(long num)
,向流中写入八个字节- 注意:
- Netty提供的
writeXX
方法,其原理都是截短固定长度的二进制位,比如:
ByteBuf byteBuf = Unpooled.buffer(); byteBuf.writeByte(256); // 结果是0,因为256的二进制编码为1 0000 0000 System.out.println(byteBuf.readByte());
- 对于
writeByte
,writeShort
和writeInt
,这三个方法都接收int
类型参数- 对于无符号的byte和short,这样并没有问题,只要不超出量程(byte:0~255,short:0~65535),即可正确写入
- 但是对于无符号的int,Java的int类型无法正确表达无符号数,我们需要特殊处理:
/** * 按大端序写无符号整型 * 大端序:先传递高 24 位,然后传递高 16 位,再传递高八位,最后传递低八位。 * @param num * @return */ public static byte[] writeUnsignedInt(long num){ byte[] bytes = new byte[4]; for (int i = 3; i >= 0; i--) { bytes[3-i] = (byte)(num >> (8 * i)); } return bytes; }
- 如果有需要使用超出量程的writeLong,需要类似处理
- Netty提供的
2.2.1、解码
- Netty的
ByteBuf
类提供了针对不同数据类型的两个系列的解码方法get
系列get
系列大体和read
相同,唯一不同之处在于其只读取readIndex所在的位置的数据,而不移动readIndex指针
read
系列- 按照有符号数读取
// 读取一个字节 byte b = byteBuf.readByte(); // 读取两个个字节 short s = byteBuf.readShort(); // 读取三个字节 int i1 = byteBuf.readMedium(); // 读取四个字节 int i2 = byteBuf.readInt(); // 读取八个字节 long l = byteBuf.readLong();
- 按照无符号数读取
// 读取一个字节 short s = byteBuf.readUnsignedByte(); // 读取两个个字节 int i1 = byteBuf.readUnsignedShort(); // 读取三个字节 int i2 = byteBuf.readUnsignedMedium(); // 读取四个字节 long l1 = byteBuf.readUnsignedInt();
- 注意:
- 无符号数版本返回的类型要“高”一级
- 无符号数版本没有读取无符号Long的方法,因为Java没有对应的基本类型
- 虽然类型返回的类型不同(无符号数返回的类型要“高”一级),但是按照有符号数读取和按照无符号数读取,两种方式读取的字节数是一样的
- 按照有符号数读取
3、特殊格式编码方法
3.1、8421 BCD码
- 在通信协议中,我们有时候还会用一种特殊的编码:8421 BCD码。
- 二进制编码的十进制数,简称BCD码(Binarycoded Decimal)。
- 这种方法是用4位二进制码的组合代表十进制数的0,1,2,3,4,5,6 ,7,8,9 十个数符。4位二进制数码有16种组合,原则上可任选其中的10种作为代码,分别代表十进制中的0,1,2,3,4,5,6,7,8,9 这十个数符。
- 最常用的BCD码称为8421BCD码,8.4.2.1 分别是4位二进数的位取值。
- 注意:
- 8421 BCD码转化的数字字符串要比原数字字符串占据的长度短一半,是一种良好的压缩方式
- 对应数值本身不用于计算,基本类型难以存储的数字字符串(比如手机号码),协议中常用BCD码
- 对于偶数位数的数字,BCD码可以良好表达,而对于奇数位数的数字,比如
12345
,则需要一些处理,其和123456
一般使用三个字节表达,差别在于最后一个字节只有后四个位有意义。 - 小技巧:数字的8421 BCD码和数字转16进制后逐字节拼接结果相同,可用于快速实现BCD码的编码
- 代码示例:
/** * 解析BCD码,获得解码后的字符串 * @param BCDSize BCD数组的size */ public static String getBCDString(ByteBuf msg,int BCDSize) { StringBuilder startTimeBuilder = new StringBuilder(); for (int i = 0; i < BCDSize; i++) { startTimeBuilder.append(FromByteUtil.decodeBCDNum(msg.readByte(), false)); } return startTimeBuilder.toString(); } /** * 解析BCD码 * @param b * @param isHalf 标注是否只有后四个位有数字,前四个位填充0 * @return */ public static String decodeBCDNum(byte b,boolean isHalf){ int i1 = b&0xff; String num = String.format("%02X",i1); if (isHalf){ num = num.substring(0, 1); } return num; } /** * 将BCD码转为字节数组 * @param size 字节数组的大小,而不是字符串长度 * @param BCDCode * @return byte[] */ public static byte[] BCDStringToBytes(String BCDCode,int size) { if (BCDCode == null || BCDCode.equals("")) { return new byte[size]; } int BCDSize = BCDCode.length() / 2; char[] hexChars = BCDCode.toCharArray(); byte[] d = new byte[size]; int n = Math.min(size, BCDSize); for (int i = 0; i < n; i++) { int pos = i * 2; d[i] = (byte) (charToByte(hexChars[pos]) << 4 | charToByte(hexChars[pos + 1])); } //如果不是偶数,说明最后还有一个单独的数字 if ((BCDCode.length() & 0x01)==1){ d[BCDSize] = (byte) (charToByte(hexChars[BCDSize * 2]) << 4 | 0); BCDSize ++; } //不足位数,在最后补零 for (int i = 0; i < size - BCDSize; i++) { d[BCDSize+i] = 0x00; } return d; } /** * Convert char to byte * @param c char * @return byte */ private static byte charToByte(char c) { return (byte) "0123456789ABCDEF".indexOf(c); }
3.2、一般字符串
- 一般字符串的编码、解码都可通过Java本身的API实现,注意前后encoding相同即可
- 示例代码:
/** * 将字符串转为指定长度的数组,不足位数,在前面补0 * @param string * @param byteSize * @return */ public static byte[] StringToByte(String string,int byteSize) throws UnsupportedEncodingException { //如果传入的字符串为null,直接返回指定长度的byte数据,全部填充0x00 if (string == null){ return new byte[byteSize]; } byte[] strBytes = string.getBytes("GBK"); int strLength = strBytes.length; if (strLength <byteSize){ byte[] bytes = new byte[byteSize]; //先补0 int diff = byteSize - strLength; for (int i = 0; i < diff; i++) { bytes[i] = 0; } //再写字符串 for (int i = 0; i < strLength; i++) { bytes[diff+i] = strBytes[i]; } return bytes; }else if (strLength>byteSize){ //截短过长的字符串 return Arrays.copyOf(strBytes, byteSize); }else{ return strBytes; } } /** * 从ByteBuf中读取指定字节长度的字符串 * 并且输出时去除头尾的空格符 * @param msg * @param byteSize * @param charset * @return * @throws UnsupportedEncodingException */ public static String getString(ByteBuf msg,int byteSize,String charset) throws UnsupportedEncodingException { if (msg.readableBytes()<byteSize){ throw new IllegalArgumentException("数据长度不够!"); } byte[] bytes = new byte[byteSize]; msg.readBytes(bytes); String s = new String(bytes, charset); return s.trim(); }
3.3、按位取值
- 有时候我们需要判断某一个或多个字节某一个位是否为1
- 一般可以通过
&
运算来进行判断,比如要判断第一个位是否为1,可以:
int targetNum = 55; Integer ref = Integer.valueOf("0001", 2); boolean b = (targetNum & ref) == ref;
- 一般我们可能需要定义一个枚举类,但是写大串的01过于冗长,并且容易出错,可以利用二进制数和十六进制数的小技巧实现: 二进制数 | 十六进制数 ---|--- 0000 0001 | 0x01 0000 0010 | 0x02 0000 0100 | 0x04 0000 1000 | 0x08 0001 0000 | 0x10 0010 0000 | 0x20 0100 0000 | 0x40 1000 0000 | 0x80 ... 依此类推更高位,1248循环|
- 示例枚举:
/** * Created by 蔡志杰 on 2017/8/7. * 文本标识 */ public enum TextFlag { /** * 紧急 */ EMERGENCY(0x01), /** * 终端显示器显示 */ LCD(0x04), /** * 终端TTS播读 */ TTS(0x08), /** * 广告屏显示 */ ADVERTISE_LCD(0x10), /** * 1:CAN故障码信息或,0:中心导航信息 */ CAN_FAILURE_INFO(0x20); private int code; TextFlag(int code){ this.code = code; } /** * 根据码获取命令标识对象 * @param code * @return */ public static TextFlag getTextFlag(int code){ for (TextFlag textFlag : TextFlag.values()) { if (textFlag.code == code) { return textFlag; } } throw new IllegalArgumentException(); } public long getCode(){ return this.code; } }
- 示例使用:
// 判断第1、3位是否均为1 int num = 55; long ref = TextFlag.EMERGENCY.getCode() | TextFlag.TTS.getCode(); boolean b = (55 & ref) == ref;
- 一般可以通过
5、注意事项
5.1、Java中的数字比较问题
Java中的整数以有符号数的形式存储,并且不提供无符号类型。但是大多数的通信协议中,使用的都是无符号数。这就导致在解析的时候,我们需要注意有符号数、无符号数的转换。
注意:
- 不超出范围(Java中整数范围)的有符号正整数、无符号正整数并没有任何区别。
- 比如有符号的
byte = 126
,和无符号的byte = 126
,其二进制码一模一样。
- 比如有符号的
- 但是需要注意,如果使用包装类的进行比较:
- 包装类型和基本类型进行比较没有问题
- 基本类型和基本类型进行比较同样没有问题
- 但是需要注意包装类型和包装类型之间的比较
public static void main(String[] args) { Integer i1 = 128; Integer i2 = 128; int i3 = 128; System.out.println(i1==i2); // 返回false System.out.println(i1==i3); // 返回true // 当数字大小在-128~127之间时,包装类对象存在同一个区域,返回true Integer i4 = 127; Integer i5 = 127; int i6 = 127; System.out.println(i4==i5); // 返回true System.out.println(i4==i6); // 返回true }