一、TCP数据流
1、系统缓冲区
当收到对端数据时,操作系统会将数据存入到Socket的接收缓冲区中。操作系统层面上的缓冲区完全由操作系统操作,程序并不能直接操作它们,只能通过socket.Receive、socket.Send等方法来间接操作。
Socket的Receive方法只是把接收缓冲区的数据提取出来,比如调用Receive(readBuff,0,2),接收2个字节的数据到readbuff(byte数组)。当系统的接收缓冲区为空,Receive方法会被阻塞,直到里面有数据。
2、粘包半包
如果发送端快速发送多条数据,接收端没有及时调用Receive,那么数据便会在接收端的缓冲区中累积。假如客户端先发送“1、2、3、4”四个字节的数据,紧接着又发送“5、6、7、8”四个字节的数据。等到服务端调用Receive时,服务端操作系统已经将接收到的数据全部写入缓冲区,共接收到8个数据。所以这会导致有时候将两条消息当成一条消息来处理——粘包
发送端发送的数据还有可能被拆分,如发送“HelloWorld”,但在接收端调用Receive时,操作系统只接收到了部分数据,如“Hel”,在等待一小段时间后再次调用Receive才接收到另一部分数据“loWorld”。这会导致有时候将一条消息当成两条消息来处理!——半包
二、解决粘包问题的方法
1、长度消息法
长度信息法是指在每个数据包前面加上长度信息。每次接收到数据后,先读取表示长度的字节,如果缓冲区的数据长度大于要取的字节数,则取出相应的字节,否则等待下一次数据接收。
游戏程序一般会使用16位整型数或32位整型数来存放长度信息,16位整型数的取值范围是0~65535,32位整型数的取值范围是0~4294967295。对于大部分游戏,网络消息的长度很难超过65535字节,使用16位整型数来存放长度信息较合适。
2、固定长度法
每次都以相同的长度发送数据,假设规定每条信息的长度都为10个字符,那么发送“Hello”“Unity”两条信息可以发送成“Hello...”“Unity...”,其中的“.”表示填充字符,是为凑数,没有实际意义,只为了每次发送的数据都有固定长度。
3、结束符号法
规定一个结束符号,作为消息间的分隔符。假设规定结束符号为“$”,那么发送“Hello”“Unity”两条信息可以发送成“Hello$”“Unity$”。接收方每次读取数据,直到“$”出现为止,并且使用“$”去分割消息。
三、大小端问题
常用的X86结构是小端模式,很多的ARM、DSP都为小端模式,但KEIL C51则为大端模式,有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。也就是说市面上的手机有些采用大端模式,有些采用小端模式。
大端模式还原这个数字的步骤是:
- 拿到第1个字节的数据00000001,乘以进制位256(2的8次方),得到256,即第1个字节(低地址)代表了十进制数字256;
- ·拿到第2个字节的数据00000010,它代表十进制数字2,乘以进制位1,得到2;
- ·将前两步得到的数字相加,即256+2,得到258,还原出数字。
小端模式还原这个数字的步骤是:
- ·拿到第2个字节的数据00000001,乘以进制位256(2的8次方),得到256,即第2个字节(高地址)代表了十进制数字256。
- ·拿到第1个字节的数据00000010,它代表十进制数字2,乘以进制位1,得到2。
- ·将前两步得到的数字相加,即256+2,得到258,还原出数字。
为了兼容所有的机型,我们规定,写入缓冲区的数字,必须按照小端模式来存储。有两种方法可以做到大小端兼容,下面分别介绍。
1、使用Reverse()函数兼容大小端代码
如果使用BitConverter.GetBytes将数字转换成二进制数据,转换出来的数据有可能基于大端模式,也有可能基于小端模式。因为我们规定必须使用小端编码,一个简单的办法是,判断系统是否是小端编码的系统,如果不是,就使用Reverse()方法将大端编码转换为小端编码。
2、手动还原数值
以接收数据为例。在下面的代码中,readBuff[0]代表缓冲区的第1个字节,readBuff[1]代表缓冲区的第2个字节,(readBuff[1]<<8)代表将缓冲区第2个字节的数据乘以256,中间的“|”代表逻辑与,在这里等同于相加。
public void OnReceiveData(){
//消息长度
if(buffCount <= 2)
return;
//消息长度
Int16 bodyLength = (short)((readBuff[1] << 8) | readBuff[0]);
Debug.Log("[Recv] bodyLength=" + bodyLength);
//消息体、更新缓冲区
//消息处理、继续读取消息
……
}
四、ByteArray类
ByteArray是封装byte[]、readIdx和length的类,使用了长度消息法解决粘包问题、手动还原数组解决大小端问题。
using System; public class ByteArray { //默认大小 const int DEFAULT_SIZE = 1024; //初始大小 int initSize = 0; //缓冲区 public byte[] bytes; //读写位置 public int readIdx = 0;//表示在bytes数组中读取数据的下标 public int writeIdx = 0;//表示在bytes数组中写入数据的下标 //容量 private int capacity = 0; //剩余空间 public int remain { get { return capacity - writeIdx; } } //数据长度,写入数据的下标和读取数据的下标直接的数据就是有效数据(还未被读取的数据) public int length { get { return writeIdx - readIdx; } } //构造函数 public ByteArray(int size = DEFAULT_SIZE) { bytes = new byte[size]; capacity = size; initSize = size; readIdx = 0; writeIdx = 0; } //构造函数 public ByteArray(byte[] defaultBytes) { bytes = defaultBytes; capacity = defaultBytes.Length; initSize = defaultBytes.Length; readIdx = 0; writeIdx = defaultBytes.Length; } //重设尺寸,剩余空间小于需要写入数据大小时调用。 public void ReSize(int size) { if (size < length) return; if (size < initSize) return; int n = 1; while (n < size) n *= 2;//虽然传入的size不一定是2的指数倍,这里会找到距离size最近的2的指数倍作为重设尺寸 capacity = n; byte[] newBytes = new byte[capacity]; //Copy函数表示把bytes数组的readIdx位开始,复制到newBytes数组的0位开始,复制的长度位writeIdx - readIdx Array.Copy(bytes, readIdx, newBytes, 0, writeIdx - readIdx);//将原本的缓冲区copy到新的缓存区内。 bytes = newBytes;//把新数组赋值给bytes //进行一系列操作之后,readIdx就又到第0位了,writeIdx就等于未读取数据的长度了 writeIdx = length; readIdx = 0; } //在某些情形下,例如有效数据长度很小(这里设置为8),或者数据全部被读取时(readIdx==writeIdx), //可以将数据前移,增加remain,避免bytes数组过长。由于数据很少,程序执行的效率不会有影响。 //检查并移动数据 public void CheckAndMoveBytes() { //因为写入数据的时候是从writeIdx下标位置开始写的,bytes数组迟早会满, //然后readIdx下标前面的数据是已经读取过的了,是无效数据了,所以可以在适当的时候覆盖掉 if (length < 8) { MoveBytes(); } } //移动数据 public void MoveBytes() { Array.Copy(bytes, readIdx, bytes, 0, length); writeIdx = length; readIdx = 0; } /// <summary> /// 写入数据 /// </summary> /// <param name="bs">写入的数据数组</param> /// <param name="offset">从bs数组的第几位开始写入</param> /// <param name="count">写入的长度</param> /// <returns></returns> public int Write(byte[] bs, int offset, int count) { //如果剩余的长度不够,则需要重新设置bytes大小。 if (remain < count) { ReSize(length + count); } //将bs数组对应的部分copy到bytes数组的writeidx下标处开始 Array.Copy(bs, offset, bytes, writeIdx, count); writeIdx += count;//writeIdx后移 return count; } /// <summary> /// 读取数据 /// </summary> /// <param name="bs">将读取的数据写入的数组</param> /// <param name="offset">把数据写入到offset的第几位</param> /// <param name="count">需要读取数据的大小</param> /// <returns></returns> public int Read(byte[] bs, int offset, int count) { count = Math.Min(count, length); Array.Copy(bytes, 0, bs, offset, count); readIdx += count;//readIdx后移 CheckAndMoveBytes();//检查并移动数据 return count; } //读取Int16,本案例使用16整形来存放长度信息 public Int16 ReadInt16() { //如果长度小于0,说明长度消息不完整,直接return if (length < 2) return 0; //通过前两位字节,获取长度信息 Int16 ret = (Int16)((bytes[1] << 8) | bytes[0]); readIdx += 2;//readIdx后移两位 CheckAndMoveBytes();//每次readIdx增加时,都可以check一下 return ret; } //读取Int32, public Int32 ReadInt32() { if (length < 4) return 0; Int32 ret = (Int32)((bytes[3] << 24) |(bytes[2] << 16) |(bytes[1] << 8) |bytes[0]); readIdx += 4; CheckAndMoveBytes(); return ret; } //打印缓冲区(仅为调试) public override string ToString() { return BitConverter.ToString(bytes, readIdx, length); } //打印调试信息(仅为调试) public string Debug() { return string.Format("readIdx({0}) writeIdx({1}) bytes({2})", readIdx, writeIdx, BitConverter.ToString(bytes, 0, bytes.Length) ); } }