第 4 篇:TCP 字节流中的粘包、半包与机器人协议解析
上一篇,我们已经把机器人 TCP 长连接的稳定性问题串了起来。
一个真正可用的 TCP 模块,不只是:
connect()
↓
write()
↓
read()
而是还需要处理:
- 心跳
- 掉线检测
- 自动重连
- 发送队列
- 统一接收
- 生命周期
- 连接状态机
但是当 TCP 连接真正跑起来以后,很快又会遇到另外一个经典问题:
为什么我明明发送了一条消息,接收端却不一定刚好 read() 到这一条消息?
甚至有时候:发送了两条消息 ,接收端一次 read() 全读出来了。
或者:发送了一条消息 ,接收端却分两三次才读完整。
这就是 TCP 开发中经常提到的:
粘包和半包。
而理解这个问题的关键,只有一句话:
TCP 是字节流协议,不是消息协议。
这一篇,我们就把这个问题彻底拆开。
一、先纠正一个很常见的理解
假设 Android 连续发送两条消息:
Message A
Message B
很多人下意识会认为:
Android:
write(A)
↓
Linux 主控:
read() → A
然后:
Android:
write(B)
↓
Linux 主控:
read() → B
也就是:一次 write() ,对应 ,一次 read()
但 TCP 根本没有这样的保证,真正的 TCP 更像是一根水管。
发送端不断把字节放进去:
A A A A B B B B
接收端从另一端取。
至于每次取多少:由接收缓冲区、操作系统、网络状况等因素共同决定。
所以:
write() 的次数和 read() 的次数没有一一对应关系。
这一点非常重要。
二、TCP 为什么没有“一条消息”的概念?
我们来看一个例子。
假设 Android 发送两条机器人消息:
第一条:
START_TASK
第二条:
GET_STATUS
在应用层看来,这是两条消息。
但是到了 TCP 层以后,它看到的只是: 一串连续字节。
例如:
AA 55 00 08 01 01 XX XX AA 55 00 06 02 01 XX XX
TCP 并不知道:
前面几个字节属于 START_TASK。
后面几个字节属于 GET_STATUS。
TCP 只负责:
把这些字节可靠、有序地传送给对方。
至于:
- 哪里是一条消息的开始?
- 哪里是一条消息的结束?
完全由应用层自己定义。
所以机器人通信协议必须自己解决:
消息边界。
三、什么是粘包?
假设 Android 连续发送:消息 A 、消息 B
我们希望主控读取:
第一次 read()
得到:
A
第二次 read()
得到:
B
但实际有可能变成:
第一次 read()
直接得到:A + B
也就是:
两条消息粘在一起了。
例如发送:
Message A:
AA 55 00 06 01 01
Message B:
AA 55 00 06 02 01
接收端一次 read() 可能直接得到:
AA 55 00 06 01 01 AA 55 00 06 02 01
也就是:
A + B
这就是我们通常说的:粘包。
但是严格来说:
TCP 并没有真的把两个“包”粘起来。
因为 TCP 本身压根不知道什么叫 A 包和 B 包。
所谓粘包,其实只是:
应用层一次读取到了多条完整消息。
四、什么是半包?
除了粘包,还有另外一种情况。
Android 发送一条完整消息:
AA 55 00 10 01 01 11 22 33 44 55 66 77 88
我们希望主控一次 read() 全部读出来。
但实际第一次可能只读到:
AA 55 00 10 01 01 11 22
剩下的:
33 44 55 66 77 88
下一次 read() 才收到。
也就是说:一条完整消息 。被拆成了:
第一段
第二段
这就是我们常说的:半包。
更准确地说应该是:
一次读取只拿到了一条消息的一部分。
五、为什么会出现粘包和半包?
原因其实并不神秘。
TCP 有自己的:
- 发送缓冲区
- 接收缓冲区
- 操作系统调度
- 网络数据分段
而应用层调用:
write()
只是把数据交给 TCP。
并不是说:write() 一次 ,网络就必须发送一个独立数据块。
比如你连续调用:
write(A)
write(B)
操作系统完全可能把两个数据一起发送。
于是接收端一次 read():
A + B
这就是所谓粘包。
反过来:
如果一条消息比较大,或者接收端一次读取的 Buffer 比较小,就可能:
一条消息
分多次 read()
这就是所谓半包。
所以:
粘包和半包不是异常,而是 TCP 字节流的正常表现。
真正错误的是:应用层错误地假设 ,一次 read() = 一条消息。
六、错误的 TCP 接收方式是什么样的?
例如:
val buffer = ByteArray(1024)
while (true) {
val len = inputStream.read(buffer)
if (len > 0) {
handleMessage(buffer.copyOf(len))
}
}
这段代码本身读取数据没有问题。
问题出在:
handleMessage()
如果它认为:每次传进来的 ByteArray ,一定是一条完整消息,那就出问题了。
因为这次 read() :
- 可能是:半条消息。
- 也可能是:两条消息。
- 甚至可能是:一条半消息。
例如:
Message A
Message B 的前半部分
所以正确思路应该是:
read()
↓
得到一段字节
↓
先放入接收缓冲区
↓
从缓冲区里解析完整消息
↓
解析出几条,就处理几条
↓
剩余半包继续保留
↓
等待下一次 read()
这就是:
协议解析器。
七、为什么机器人协议通常需要“长度字段”?
如果 TCP 不知道消息边界,那应用层怎么知道:
这一条消息到底有多长?
一个非常常见的方法就是:
在协议里增加:Length。
例如设计:
Header
Length
Command
Sequence
Data
Checksum
可以简单理解成:
| 字段 | 作用 |
|---|---|
| Header | 找到一条消息的开始位置 |
| Length | 告诉接收端整条消息多长 |
| Command | 表示消息类型 |
| Sequence | 消息编号 |
| Data | 业务数据 |
| Checksum | 校验数据是否合法 |
例如:
AA 55 | 00 0C | 10 01 | 00 01 | DATA | CRC
这里:
AA 55 代表包头。
00 0C 代表消息长度。
10 01 代表 Command。
00 01 代表 Sequence。
接收端一旦读取到:
Header + Length
就可以知道:这一条完整消息一共需要多少字节。
于是消息边界问题就可以解决。
八、为什么需要包头 Header?
假设我们只有 Length。
理论上是不是也能解析?
正常情况下可以。
但实际长期通信过程中可能发生:
- 异常数据
- 错位
- 协议解析失败
- 某些字节丢失
- 非法数据
这时候解析器就可能失去正确边界。
所以很多二进制协议还会设计固定包头。
例如:AA 55
或者:55 AA
甚至:0xCA 0xFE
它的作用就是:
帮助解析器重新找到一条消息的起点。
例如缓冲区当前数据:
33 42 12 AA 55 00 0C ...
前面的:
33 42 12
都不是有效数据。
解析器扫描到:AA 55 以后才认为 这里可能是一条合法消息的开始。
所以 Header 的一个重要作用就是:
同步消息边界。
九、一个典型机器人协议可以怎么设计?
我们可以设计一个简单的机器人协议:
Header
2 字节
Length
2 字节
Command
2 字节
Sequence
4 字节
Data
N 字节
Checksum
2 字节
结构:
Header
↓
Length
↓
Command
↓
Sequence
↓
Data
↓
Checksum
例如:
AA55 | 0010 | 1001 | 00000025 | XXXXXXXX | CRC
假设:
Command = 0x1001
表示:
GO_TO_POINT
那么 Data 里可能保存:
pointId
例如:
pointId = 15
接收端解析以后得到:
RobotMessage(
command = GO_TO_POINT,
sequence = 25,
data = ...
)
然后再交给业务层处理。
TCP 层看到的是:ByteArray
协议层看到的是:RobotMessage
业务层看到的是:GoToPointCommand
这几个层级一定要分开。
十、接收缓冲区到底是什么?
这是解决粘包、半包的核心。
假设当前:
socket.read()
读到了:
10 个字节。
但通过 Length 判断:
一条完整消息需要:
20 个字节。
这时候不能直接解析。
而是:
把这 10 个字节先留下。
例如:
ReceiveBuffer:10 bytes
下一次 read():又收到 15 bytes。
现在 Buffer:25 bytes
这时候发现:前 20 bytes
刚好组成一条完整消息。
于是:
取出前 20 bytes -》解析 Message A 。
剩余:5 bytes 继续留在缓冲区。
因为这 5 个字节可能是:下一条消息的前半部分。
所以整个过程其实就是:
ReceiveBuffer
↓
查找 Header
↓
读取 Length
↓
判断数据是否足够
↓
足够:
取出完整包
↓
解析
↓
继续解析剩余 Buffer
↓
不足:
等待下一次 read()
十一、如果一次收到两条消息怎么办?
假设:
Message A = 20 bytes
Message B = 16 bytes
结果一次 read():收到 36 bytes。
这时候:ReceiveBuffer = 36 bytes
解析器发现:前 20 bytes 是一条完整 Message A。
于是先取出:20 bytes 解析 A。
此时 Buffer 还剩:16 bytes 然后继续解析:发现又刚好是一条完整 Message B。
于是:继续解析 B。
最终 Buffer:0 bytes 。
所以:
一次 read() 可以解析出多条消息。
这就是粘包的标准处理方式。
不是:阻止粘包发生。
而是:正确解析。
十二、如果只收到半条消息怎么办?
反过来:
协议 Length 表示:完整消息需要 20 bytes。
但是当前 Buffer:只有 12 bytes。
怎么办?
什么都不要做。
继续等待。
下一次 read():收到 8 bytes。
于是 Buffer:20 bytes。
现在:完整消息已经到齐。
再开始解析。
所以:半包的处理核心就是保留未完成数据。
千万不要:第一次 read() 没读完整 ,就把这 12 个字节丢掉。
否则下一次收到:剩余 8 bytes 已经完全不知道 它们属于谁了。
十三、一个解析循环可以怎么理解?
我们先不纠结具体 Kotlin 代码。
从思想上理解,一个解析器大概是:
收到新的 ByteArray
↓
追加到 ReceiveBuffer
↓
while (true)
查找 Header
↓
数据够不够读取 Length?
不够
↓
等待下一次数据
↓
读取 Length
↓
当前 Buffer 是否达到完整包长度?
不够
↓
等待下一次数据
↓
取出完整数据包
↓
校验
↓
解析 Message
↓
删除已消费字节
↓
继续 while
这才是 TCP 接收端真正的核心逻辑。
十四、为什么需要 Command?
一条数据成功拆出来以后:还只是 ByteArray。
接下来怎么知道它是什么消息?
这时候就需要:Command。
例如:
0x1001
START_TASK
0x1002
STOP_TASK
0x1003
PAUSE_TASK
0x2001
ROBOT_STATUS
0x2002
BATTERY_STATUS
0x3001
NAVIGATION_STATUS
接收端读取:
Command
以后:
再决定如何解析 Data。
例如:
when(command) {
ROBOT_STATUS -> parseRobotStatus()
BATTERY_STATUS -> parseBatteryStatus()
NAVIGATION_STATUS -> parseNavigationStatus()
}
最终得到真正的业务对象。
所以:
Length = 解决消息边界。
Command = 解决消息类型。
十五、Sequence 又有什么作用?
Sequence 在上一篇已经提到过。
它相当于:消息编号。
例如 Android 发送:
Sequence = 101
START_TASK
主控执行以后返回:
Sequence = 101
ACK
于是 Android 就知道:
这个 ACK
对应刚才那条 START_TASK。
否则如果短时间连续发送:
Command A
Command B
Command C
主控不断返回:
SUCCESS
SUCCESS
FAILED
Android 怎么知道:
哪个结果对应哪个指令?
这就是 Sequence 的作用之一。
后面讲:
- 指令回执
- 超时
- 重试
的时候,我们还会继续用到。
十六、Checksum 是解决什么问题的?
有些机器人协议最后还会放:
CRC
Checksum
例如:
Header
Length
Command
Data
CRC16
接收端解析完以后:
重新计算 CRC
然后和协议中的 CRC 对比。
如果一致:认为消息合法。
如果不一致:认为数据异常。
然后:
- 丢弃该消息
- 记录日志
- 重新寻找下一个 Header
需要注意的是:
TCP 自己已经有校验和和重传机制。
所以应用层 CRC 是否必要,取决于你的设备链路和协议设计。
但在机器人内部通信、串口协议或者历史已有协议中,经常还是可以看到:
Checksum / CRC。
特别是当:同一套协议还可能运行在串口、RS485 等链路上时,应用层校验往往会继续保留。
十七、遇到错误包以后为什么不能直接崩掉?
长期运行的机器人一定会遇到异常情况。
例如:
- Header 错误
- Length 非法
- 消息太长
- Checksum 错误
- 未知 Command
- 数据解析失败
这时候协议解析器不能:throw Exception 然后整个接收协程结束。
否则一次坏消息可能直接导致:机器人通信彻底断掉。
更合理的方式是:
发现异常包
↓
记录日志
↓
丢弃异常数据
↓
重新寻找合法 Header
↓
继续解析下一条消息
例如 Length 规定最大:
64 KB
结果某次解析出来:Length = 500 MB 显然不合理。
这时候应该立即判断:非法长度。
而不是:真的尝试分配 500 MB 内存。
所以协议解析器还承担一个非常重要的职责:防御异常数据。
十八、为什么一定要限制最大包长度?
这是一个非常实际的工程问题。
如果协议里:
Length
完全相信对端传来的值。
那么收到:
Length = 2GB
程序可能尝试:等待 2GB 数据
甚至分配一个巨大数组。
这很容易导致:OOM
所以应该定义:MAX_PACKET_SIZE
例如:
64 KB
或者:
1 MB
具体根据业务决定。
然后判断:
如果:length <= 0
或者:length > MAX_PACKET_SIZE
那么:
认为协议异常。
这就是基础的:输入校验。
机器人长期运行系统尤其需要这种防御性设计。
十九、协议层和业务层为什么必须分开?
现在整个链路其实已经越来越清楚了。
最底层:Socket
只负责:read / write
上一层:Protocol Parser
负责:
Header
Length
Command
Sequence
Checksum
再上一层:
Message Dispatcher
负责:不同 Command 分发。
然后才到:业务层。
例如:
Socket
↓
ByteArray
↓
Protocol Parser
↓
RobotMessage
↓
Dispatcher
↓
NavigationMessage
TaskMessage
DeviceMessage
↓
Repository / UseCase / ViewModel
这样 Android 页面完全不需要知道:
AA55
Length
ByteArray
CRC
这些东西。
页面真正看到的应该是:
RobotState
NavigationState
TaskState
DeviceState
这就是一个比较健康的通信架构。
二十、不要让 ViewModel 自己拆 TCP 包
这个错误其实非常常见。
项目早期为了开发快:
Socket 收到消息
↓
直接丢到 ViewModel
↓
ViewModel 判断:
if (command == xxx)
↓
解析 ByteArray
↓
更新 UI
短期看起来很快。
但是业务一多:一个 ViewModel 里可能出现几十种 Command。
最终:
TCP 协议
业务逻辑
UI 状态
全部混在一起。非常难维护。
更合理的方式是:
TCP Manager负责连接。
Protocol Parser负责拆包。
Dispatcher负责分发。
Repository负责业务转换。
ViewModel只负责 UI 状态。
例如:
TCP
↓
RobotProtocolParser
↓
NavigationRepository
↓
NavigationViewModel
这样就清晰很多。
二十一、TCP 协议解析本质上就是一个状态机
如果再往深一点理解:
TCP Parser 本身其实也可以看成一个状态机。
例如:
WAIT_HEADER
↓
READ_LENGTH
↓
WAIT_BODY
↓
VERIFY
↓
PARSE
↓
WAIT_HEADER
假设没找到 Header:继续等待。
找到 Header:读取 Length。
数据不够:等待。
数据够:校验。
校验成功:解析。
然后重新进入:WAIT_HEADER。
所以一个稳定的协议解析器,本质上解决的是:
在连续字节流里不断恢复消息边界。
二十二、把整个接收链路串起来
现在我们可以完整看一下机器人 TCP 数据是怎么进来的。
Linux 主控
↓
TCP
↓
Android Socket
↓
InputStream.read()
↓
ByteArray
↓
ReceiveBuffer
↓
查找 Header
↓
读取 Length
↓
判断完整包
↓
Checksum
↓
解析 Command / Sequence / Data
↓
RobotMessage
↓
Message Dispatcher
↓
Task / Navigation / Device 模块
↓
更新业务状态
↓
ViewModel
↓
Compose UI
这条链路跑通以后:TCP 字节流 才真正变成:Android 能理解的机器人状态。
二十三、发送端其实也应该走同一套协议
接收如此。
发送也一样。
业务层不应该自己拼:
ByteArray。
例如业务层调用:
robotClient.goToPoint(15)
然后:
GoToPointCommand
↓
ProtocolEncoder
↓
Header
Length
Command
Sequence
Data
Checksum
↓
ByteArray
↓
SendQueue
↓
Socket.write()
这样:编码和解码 是一套对称结构。
也就是
发送:
Business Object
↓
Protocol Encoder
↓
ByteArray
接收:
ByteArray
↓
Protocol Decoder
↓
Business Object
整个通信层会非常清楚。
二十四、总结
这一篇最重要的只有一个认知:
TCP 是字节流,不是消息协议。
因此:
一次 write() 不等于 一次 read()
一次 read() 也不等于 一条完整消息。
所以机器人 TCP 协议通常需要自己定义消息格式:
Header
↓
Length
↓
Command
↓
Sequence
↓
Data
↓
Checksum
接收端则需要:
Socket.read()
↓
ReceiveBuffer
↓
寻找包头
↓
读取长度
↓
判断完整消息
↓
拆包
↓
校验
↓
解析
↓
业务分发
所谓:粘包
其实是:一次读取到了多条消息。
所谓:半包
其实是:一条消息需要多次读取才能完整。
它们都不是 TCP 的 Bug。
而是 TCP 字节流的正常行为。
真正要做的是:
在应用层建立可靠的协议解析机制。
到这里:
TCP 连接建立了。
心跳、重连解决了。
粘包、半包也解决了。
但是机器人控制还有一个比“消息能不能送到”更重要的问题:
Android 发送:OPEN_DOOR
TCP 成功发送出去,就能说明:柜门已经打开了吗?
不能。
因为:
通信成功,不等于机器人执行成功。
所以接下来就要进入机器人控制协议里非常核心的一层:
- Command
- Ack
- Sequence
- Timeout
- Retry
下一篇:
第 5 篇《机器人指令、回执、超时和重试怎么设计?》
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)