第 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 接收方式是什么样的?

例如:

Kotlin 复制代码
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。

例如:

Kotlin 复制代码
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 对比。

如果一致:认为消息合法。

如果不一致:认为数据异常。

然后:

  1. 丢弃该消息
  2. 记录日志
  3. 重新寻找下一个 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 篇**《机器人指令、回执、超时和重试怎么设计?》**

相关推荐
某林2121 小时前
从“仿真能跑”到“真机能走”:一套轮腿机械狗强化学习系统的工程化实践
人工智能·stm32·嵌入式硬件·架构·机器人·机械狗
消失的旧时光-19433 小时前
补充篇:机器人为什么都需要“指令中心”?——从服务机器人到割草机器人
机器人·割草机·commandcenter
热心市民R先生3 小时前
如何在CSDN博客编辑器中导入自己的Markdown文件
机器人
M-Robots echo3 小时前
王成录:M-Robots 积木式架构,解决机器人软硬件重复开发难题
机器人·开源鸿蒙·开源社区·m-robots·王成录
热心市民R先生4 小时前
【无标题】
机器人
具身智能进化论1 天前
协作机器人产业进入规模化部署期,未来几年的增长从何而来
大数据·运维·人工智能·机器人·自动化·工厂方法模式
极新1 天前
中国机器人、AI、创新药出海:这次出海的重头戏
人工智能·机器人
Axis tech1 天前
人形机器人训练:从工人动作到机器人作业技能
机器人
M-Robots echo1 天前
王成录:分布式软总线是 M-Robots 实现群体智能的技术内核
机器人·ros·鸿蒙·开源社区·m-robots