Unity TCP底层极限拆解:粘包、半包、CRC、序列号、双缓冲到底怎么一起工作的?

前面已经把一条 NetPacket 从对象、序列化一直讲到了网络发送,但真正到了 TCP 这一层,问题才刚刚开始。

TCP 没有"消息"的概念,它只是一条连续的字节流。你发送了 3 条协议,对方一次 Receive() 可能收到半条,也可能一次把 3 条全收回来。

MyFramework 的 NetConnectTCPBit 就是在这一层处理:半包、粘包、包长、CRC、序列号、加密、多线程以及主线程分发。

项目地址:

GitHub - ZHOURUIH/MyFramework: Unity 商用级别开发框架,经过了多年经验沉淀.一个在unity上使用的网络游戏客户端开发框架,为unity所有使用方式提供完善的封装和管理,只需要专注于游戏逻辑的编写 · GitHub

一、TCP 最大的问题:它根本不知道哪里是一条消息

假设连续发送:

复制代码
Packet A
Packet B
Packet C

TCP 接收端完全可能变成:

复制代码
第一次 Receive:
[A 的一半]

第二次 Receive:
[A 剩余部分][B][C 的一部分]

第三次 Receive:
[C 剩余部分]

所以这种代码肯定不行:

复制代码
int size = mSocket.Receive(mRecvBuff);
// 直接把 mRecvBuff 当成一条协议解析

MyFramework 收到数据后第一步不是解析协议,而是先追加到:

复制代码
protected StreamBuffer mInputBuffer;

核心流程:

复制代码
int nRecv = mSocket.Receive(mRecvBuff);
using (new ThreadLockScope(mInputBufferLock))
{
	mInputBuffer.addData(mRecvBuff, nRecv);
}

于是 Socket 每次吐出来多少数据已经不重要了。

所有数据都会先变成一条连续的:

复制代码
mInputBuffer

[已经收到的数据....................]

然后再从这个缓冲区中一条一条拆协议。


二、半包怎么处理:数据不够就什么都不做

真正拆包的是:

复制代码
preParsePacket(...)

它首先尝试读取:

复制代码
PacketSize
PacketSizeCRC
PacketType
Sequence
HasSign
FieldFlag
PacketBody
CRC

任何一步发现数据不够,都返回:

复制代码
PARSE_RESULT.NOT_ENOUGH

例如:

复制代码
if (!reader.read(out uint tempPacketSize))
{
	return PARSE_RESULT.NOT_ENOUGH;
}

或者包体还没收完整:

复制代码
if (!reader.readBuffer(outPacket, packetSize))
{
	return PARSE_RESULT.NOT_ENOUGH;
}

此时不会删除 mInputBuffer 中任何东西。

直接等待下一次:

复制代码
Receive()
    ↓
继续追加数据
    ↓
再次尝试解析

所以所谓"半包处理"本质上非常简单:

数据不完整,就把它留在缓冲区里。


三、粘包怎么处理:解析成功以后继续往后拆

如果一条消息解析成功:

复制代码
mReceiveBuffer.add(new(
	packetData,
	fieldFlag,
	packetSize,
	sequence,
	packetType,
	hasSign));

随后会从输入缓冲区删除已经消费的数据:

复制代码
mInputBuffer.removeData(
	0,
	bitCountToByteCount(bitIndex));

但是代码不会结束,而是继续:

复制代码
while (true)
{
	preParsePacket(...);
	...
}

所以如果一次 Receive() 收到了:

复制代码
[A][B][C]

最终流程就是:

复制代码
解析 A
↓
删除 A
↓
解析 B
↓
删除 B
↓
解析 C
↓
删除 C

TCP 的粘包问题到这里就解决了。


四、包长不是直接相信,而是先做一次 CRC

想拆包,必须知道:

复制代码
这条消息到底有多长?

所以协议最前面首先写:

复制代码
writer.write((uint)realPacketSize);

但这里有一个危险。

如果 packetSize 自己因为数据错误被解析成:

复制代码
20 MB

后面的所有拆包逻辑都会出问题。

所以紧跟着又写了:

复制代码
writer.write(
	generateCRC16(realPacketSize));

接收时先验证:

复制代码
if (generateCRC16(packetSize) != packetSizeCRC)
{
	return PARSE_RESULT.ERROR;
}

也就是:

复制代码
PacketSize
     ↓
PacketSize CRC
     ↓
确认包长可信
     ↓
才继续读取后面的包

这是第一层校验。


五、完整消息还有第二层 CRC

除了包长 CRC,消息最后还有一次完整 CRC:

复制代码
writer.write(
	generateCRC16(
		writer.getBuffer(),
		writer.getByteCount()));

接收端先计算当前收到的数据:

复制代码
ushort curCrc = generateCRC16(
	reader.getBuffer(),
	reader.getReadByteCount());

然后再读取发送端附带的 CRC:

复制代码
reader.read(out ushort readCrc);

最终比较:

复制代码
if (curCrc != readCrc)
{
	return PARSE_RESULT.ERROR;
}

所以这里其实是两级检查:

复制代码
第一层
PacketSize CRC
防止错误包长破坏拆包

第二层
Packet CRC
验证整个消息数据

六、包体先加密,再参与完整 CRC

发送时顺序也很有意思。

业务消息首先完成 Bit 序列化:

复制代码
netPacket.write(
	mBitWriter,
	netPacket.hasSign(),
	out ulong fieldFlag);

然后先加密包体:

复制代码
mEncryptPacket?.Invoke(
	packetBodyData,
	0,
	realPacketSize,
	key);

之后才组装完整消息并计算 CRC。

所以实际顺序是:

复制代码
业务数据
↓
Bit 序列化
↓
加密 PacketBody
↓
写入协议头
↓
写入加密后的 PacketBody
↓
计算 CRC

接收端则反过来:

复制代码
接收完整数据
↓
验证 CRC
↓
提取 PacketBody
↓
解密
↓
SerializerBitRead
↓
恢复 NetPacket

这样完整 CRC 校验的是网络上真正传输的数据


七、Sequence 不负责 TCP 可靠性,而是额外检测消息连续性

每发送一个协议:

复制代码
++mSendSequenceNumber;

并写入:

复制代码
writer.write(mSendSequenceNumber);

接收时:

复制代码
if (sequence != mLastReceiveSequenceNumber + 1 &&
	mLastReceiveSequenceNumber != 0xFFFFFFFF)
{
	// 当前不直接判定消息非法
}
mLastReceiveSequenceNumber = sequence;

TCP 本身已经提供可靠、有序传输,所以这里的 Sequence 不是重新实现 TCP。

它更像是一层应用协议上的检测信息:

复制代码
上一条:100
当前:101  √

上一条:100
当前:105  ?

值得注意的是,目前源码即使发现不连续,也不会直接丢弃消息,相关错误返回被注释掉了。

这避免了一些特殊情况下因为额外检测导致误判。


八、发送不是来一条就 Socket.Send 一次

如果游戏一帧产生:

复制代码
30 条协议

最简单的方式当然是:

复制代码
Socket.Send()
Socket.Send()
Socket.Send()
...

但 MyFramework 不这么做。

协议序列化完成后先进入:

复制代码
DoubleBuffer<PacketSendInfo> mOutputBuffer;

发送线程再一次性取出这一批消息:

复制代码
using var a =
	new DoubleBufferReader<PacketSendInfo>(
		mOutputBuffer);

随后全部拼入:

复制代码
mTotalBuffer

最后统一:

复制代码
sendTotalData();

也就是:

复制代码
Packet A ┐
Packet B ├→ mTotalBuffer → Socket.Send
Packet C ┘

如果总缓冲区满了,就先发送当前数据,再继续拼。

这样可以减少大量零散的 Socket.Send() 调用。


九、接收线程绝对不直接执行游戏逻辑

收到完整协议以后,网络线程也不会马上:

复制代码
packet.execute();

而是先进入:

复制代码
DoubleBuffer<PacketReceiveInfo> mReceiveBuffer;

网络线程负责:

复制代码
Receive
拆包
CRC
提取包体
↓
mReceiveBuffer

主线程 update() 再获取:

复制代码
using var a =
	new DoubleBufferReader<PacketReceiveInfo>(
		mReceiveBuffer);

然后:

复制代码
NetPacket packet = parsePacket(...);
packet.execute();
mNetPacketFactory.destroyPacket(packet);

完整结构变成:

复制代码
接收线程
Socket.Receive
    ↓
StreamBuffer
    ↓
拆包 / CRC
    ↓
DoubleBuffer
================
主线程
    ↓
解密 / 反序列化
    ↓
NetPacket.execute()
    ↓
回收

这样 UI、角色、场景、背包等游戏对象仍然只会在主线程修改。


十、为什么这里特别适合 DoubleBuffer

如果只用普通线程安全队列:

复制代码
网络线程写
主线程读

每条消息都可能需要同步。

而双缓冲是:

复制代码
Buffer A:网络线程持续写
Buffer B:主线程稳定遍历

下一次读取:
A/B 交换

主线程拿到一个列表以后,这批数据就基本不会再被网络线程碰。

所以它非常适合这种:

一个或多个线程持续生产,一个固定线程批量消费的场景。


十一、整条 TCP 收发链路

把所有东西串起来:

复制代码
发送
NetPacket
↓
Bit序列化
↓
加密
↓
PacketSize / CRC / Type / Sequence / FieldFlag
↓
发送DoubleBuffer
↓
批量合并
↓
Socket.Send

====================

接收
Socket.Receive
↓
StreamBuffer累积
↓
处理半包 / 粘包
↓
PacketSize CRC
↓
完整CRC
↓
接收DoubleBuffer
↓
主线程
↓
解密
↓
Bit反序列化
↓
NetPacket.execute()

这里真正值得看的不是某一个 API,而是这些技术点如何组合:

StreamBuffer 解决 TCP 无消息边界,PacketSize 负责拆包,两级 CRC 检测数据异常,Sequence 提供额外连续性信息,DoubleBuffer 隔离网络线程与主线程,批量发送则降低零散 Socket 调用。

最终才组成了 NetConnectTCPBit 这条完整的 TCP 通信链路。

下一篇就可以继续拆另一个很有意思的问题:TCP 已经可靠了,游戏为什么还要 UDP?MyFramework 的 UDP 网络层又是怎么处理协议、序列号和收发线程的?

相关推荐
言乐636 分钟前
Python游戏水平测试辅助系统2
开发语言·windows·python·游戏·django
长江&后浪2 小时前
防火墙的描述
开发语言·网络·php
小新讲网安2 小时前
安全运营中心(SOC)建设实战:从SIEM到威胁狩猎全流程指南
网络·安全·web安全·网络安全·网安
SKYLAB013 小时前
打破智能家居生态壁垒|微喇智能 WKV553‑A 模组原生支持 Matter,解锁跨平台互联新体验
网络·智能家居
笨鸟先飞,勤能补拙4 小时前
Web 服务器与计算机网络全景原理
运维·服务器·前端·网络·人工智能·计算机网络·web安全
KindSuper_liu4 小时前
Unity 游戏网络基础:从 TCP、UDP 到 HTTP、TLS 与 Best HTTP
网络·游戏·unity
caimouse4 小时前
ReactOS 图形系统分析(3):显示驱动 DDI framebuf.dll
linux·运维·网络
M158227690554 小时前
四通道 Profinet 转 Modbus RTU 网关 SG-PN-Modbus_4 全场景落地详解
网络·工控布线
佳児素花痴╮4 小时前
网络问题与基础理论
运维·服务器·网络