IP/TCP/UDP 解析器:一次搞懂网络包结构
WeakNet 技术博客系列 | 第 3 篇
上一篇我们从 TUN 接口拿到了原始字节流------一个 ByteArray,里面藏着 App 发出的每一个网络包。但字节流本身没有任何语义,它只是一堆 0 和 1。要操纵这些数据包,首先得把它们"读"出来:哪些字节是源 IP,哪些字节是目标端口,哪些是 TCP 标志位,哪些是实际载荷。这就是本文要做的事。
如果你觉得 IP 头、TCP 头这些概念只在教科书里出现,那是因为你从来没有手动解析过一个真实的网络包。当你在用户态拿到一个来自 TUN 接口的 ByteArray,你必须亲手从每一个字节里提取出协议字段。没有库帮你做这件事,没有对象模型替你抽象。你要面对的就是 RFC 791、RFC 793 里那张字段布局表,以及 Kotlin 的位运算符。
本文会逐字节拆解 IP、TCP、UDP 三种头部结构,给出 WeakNet 中真实的 Kotlin 解析代码,然后讨论两个在教科书里往往一笔带过、但在实际工程中绕不过去的问题:IP 分片重组 和校验和计算。
IP 头部:20 字节里装了什么
IP 头是每个数据包的入口。不管里面装的是 TCP、UDP 还是 ICMP,外面都套着一层 IP 头。先看这张字节布局图:
text
字节偏移: 0 1 2-3 4-5 6-7 8 9 10-11 12-15 16-19
┌────┬────┬─────┬─────┬─────┬────┬────┬─────┬──────┬──────┐
│0x45│DSCP│总长 │标识 │标志 │TTL │协议 │CS=0 │源IP │目IP │
└────┴────┴─────┴─────┴─────┴────┴────┴─────┴──────┴──────┘
逐字段解释:
- 版本号 (Version, 4 bit) :对于 IPv4 来说永远是 4。提取方式:把第 0 字节右移 4 位,然后与
0x0F做 AND。为什么还要 AND?因为 Kotlin 的Byte是有符号的,右移会把符号位扩展到高位,AND 操作确保只保留低 4 位。 - 头部长度 (IHL, 4 bit) :以 32 位字(4 字节)为单位。也就是说,实际头部长度 = IHL * 4 。最常见的情况是 IHL=5,对应 20 字节------没有选项字段的"裸"IP 头。如果 IP 头带了选项(比如时间戳、路由记录),IHL 会更大。解析 TCP/UDP 头时,必须从
IHL * 4的位置开始,而不是硬编码从第 20 字节开始。 - 总长度 (Total Length, 16 bit) :整个 IP 包的字节数,包括 IP 头和载荷。这个字段很关键------TUN 接口读到的
ByteArray可能比实际 IP 包大(对齐填充),要用totalLength而不是buffer.size来确定有效数据范围。 - 协议号 (Protocol, 8 bit) :IP 头里最重要的字段之一。6 = TCP,17 = UDP,1 = ICMP。就是靠这个字段,我们才知道载荷里装的是什么协议,接下来该怎么解析。
- 源地址 / 目的地址 (各 32 bit) :4 字节的 IPv4 地址。注意它们是二进制形式(如
0x0A000002),不是点分十进制字符串。转换为"10.0.0.2"格式需要手动处理每个字节。
下面是 WeakNet 中 IpHeader 的完整实现(文件路径 vpn/packet/IpHeader.kt):
kotlin
class IpHeader(private val buffer: ByteArray, private val offset: Int = 0) {
init {
require(buffer.size >= offset + 20) {
"IpHeader: buffer too small (size=${buffer.size}, offset=$offset)"
}
}
val version: Int
get() = (buffer[offset].toInt() shr 4) and 0x0F
val ihl: Int
get() = buffer[offset].toInt() and 0x0F
val headerLength: Int
get() = ihl * 4
val totalLength: Int
get() = ByteUtils.byteArrayToShort(buffer, offset + 2)
val protocol: Int
get() = buffer[offset + 9].toInt() and 0xFF
val sourceAddress: ByteArray
get() = buffer.copyOfRange(offset + 12, offset + 16)
val destinationAddress: ByteArray
get() = buffer.copyOfRange(offset + 16, offset + 20)
val isTcp: Boolean get() = protocol == PROTOCOL_TCP
val isUdp: Boolean get() = protocol == PROTOCOL_UDP
companion object {
const val PROTOCOL_ICMP = 1
const val PROTOCOL_TCP = 6
const val PROTOCOL_UDP = 17
}
}
几个值得注意的点:
用 offset 而不是切片 。IpHeader 接收原始 ByteArray 和一个偏移量,而不是对数组做切片(copyOfRange)。这是因为整个 IP 包的数据(包括载荷)都在同一个 ByteArray 里,TcpHeader 和 UdpHeader 后续需要从同一个数组的 offset + headerLength 位置开始读取。如果每个解析器都切一次数组,会产生大量不必要的内存拷贝。
and 0xFF 无处不在 。Kotlin 的 Byte 是有符号的,取值范围是 -128 到 127。一个值为 0xC0 的字节,在 Kotlin 中会被解释为 -64。如果不做 and 0xFF,(buffer[offset + 9].toInt() shr 4) 这样的位运算会得出完全错误的结果。and 0xFF 本质上是把有符号字节转成无符号整数,这是网络协议解析中最常见的陷阱之一。
init 块做防御性检查。如果传入的 buffer 不足 20 字节,直接抛异常。这比在后续访问中越界崩溃要好得多------越界异常的错误信息往往难以定位到具体原因。
TCP 头部:连接的灵魂
TCP 头比 IP 头复杂得多。IP 头解决的是"从哪到哪"的问题,TCP 头解决的是"连接状态和数据可靠性"的问题。
text
字节偏移: 0-1 2-3 4-7 8-11 12 13 14-15
┌─────┬─────┬──────┬──────┬────┬────┬──────┐
│源端口│目端口│ seq │ ack │偏移│标志│窗口 │
└─────┴─────┴──────┴──────┴────┴────┴──────┘
关键字段:
- 源端口 / 目的端口 (各 16 bit) :TCP 连接的"两元组"。一条 TCP 连接由(源 IP、源端口、目的 IP、目的端口)四元组唯一标识,其中端口信息在 TCP 头里。端口号用大端序(Big-Endian)存储------高字节在前,低字节在后。读取 16 位无符号整数:
(buffer[offset] and 0xFF) << 8 | (buffer[offset+1] and 0xFF)。 - 序列号 (Sequence Number, 32 bit) :这是 TCP 可靠传输的核心。注意它不是包的编号,而是字节的编号。它表示这个包的载荷数据在整条字节流中的起始位置。如果当前 seq 是 1000,载荷有 500 字节,那下一个包的 seq 就是 1500。这个细节很重要------后面计算 RST 包的序列号时,必须按实际字节数递增,而不是每包加 1。
- 确认号 (Acknowledgment Number, 32 bit):告诉对方"我已经收到你发出的所有 seq < ackNumber 的字节,下一个我期望收到的字节是 ackNumber"。只有在 ACK 标志置位时才有效。
- 数据偏移 (Data Offset, 4 bit):和 IP 头的 IHL 类似,TCP 头的实际长度 = dataOffset * 4。最小值 5(20 字节),有 TCP 选项时会更大。
- 标志位 (Flags, 8 bit):TCP 的控制信号,每一位代表一种标志。
TCP 标志位是本文最值得细讲的内容。第 13 字节的 8 个 bit 分别代表不同的控制信号:
text
bit: 7 6 5 4 3 2 1 0
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ │ │URG│ACK│PSH│RST│SYN│FIN│
└───┴───┴───┴───┴───┴───┴───┴───┘
值: - - 0x20 0x10 0x08 0x04 0x02 0x01
判断某个标志是否置位,用的是按位与 操作。比如判断 SYN 标志:flags and 0x02,如果结果不为 0,说明 bit 1 是 1,即 SYN 已置位。
WeakNet 中 TcpHeader 的标志位解析(文件路径 vpn/packet/TcpHeader.kt):
kotlin
val flags: Int
get() = buffer[offset + 13].toInt() and 0xFF
val isSyn: Boolean get() = (flags and FLAG_SYN) != 0 // 0x02
val isAck: Boolean get() = (flags and FLAG_ACK) != 0 // 0x10
val isFin: Boolean get() = (flags and FLAG_FIN) != 0 // 0x01
val isRst: Boolean get() = (flags and FLAG_RST) != 0 // 0x04
val isPsh: Boolean get() = (flags and FLAG_PSH) != 0 // 0x08
companion object {
const val FLAG_FIN = 0x01
const val FLAG_SYN = 0x02
const val FLAG_RST = 0x04
const val FLAG_PSH = 0x08
const val FLAG_ACK = 0x10
}
为什么用常量而不是魔数?因为这些值在代码中出现得太多了。FLAG_SYN 在 TCP 握手判断、会话状态管理、RST 构造等十几个地方使用。如果到处写 0x02,后续维护时根本无法搜索。提取成常量后,改一处全局生效。
几个标志的组合也有明确含义:SYN + ACK(flags == 0x12)是三次握手的第二步,表示"我确认收到你的 SYN,同时也发起连接"。FIN + ACK(flags == 0x11)是四次挥手的步骤之一,表示"我确认收到你的 FIN,我也关闭了"。在 WeakNet 的 PacketProcessor 中,正是靠这些标志位的组合来驱动 TCP 状态机。
UDP 头部:简单到只需要 8 字节
和 TCP 相比,UDP 简单得令人感动。固定 8 字节头部,没有序列号,没有确认号,没有标志位,没有窗口大小。
kotlin
class UdpHeader(private val buffer: ByteArray, private val offset: Int = 0) {
init {
require(buffer.size >= offset + 8) {
"UdpHeader: buffer too small (size=${buffer.size}, offset=$offset)"
}
}
val sourcePort: Int
get() = ByteUtils.byteArrayToShort(buffer, offset)
val destinationPort: Int
get() = ByteUtils.byteArrayToShort(buffer, offset + 2)
val length: Int
get() = ByteUtils.byteArrayToShort(buffer, offset + 4)
.coerceAtMost(buffer.size - offset)
val headerLength: Int get() = 8
val payloadLength: Int
get() = (length - 8).coerceAtLeast(0)
}
四个字段,一目了然:源端口(2 字节)、目的端口(2 字节)、长度(2 字节)、校验和(2 字节)。长度字段包含头部本身,所以载荷长度 = length - 8。
UDP 的简单性也意味着它没有 TCP 那些可靠性机制:没有重传、没有拥塞控制、没有顺序保证。DNS 查询、QUIC 协议、视频流等场景使用 UDP,正是为了规避 TCP 的复杂性。在 WeakNet 中,UDP 的处理逻辑也比 TCP 简单得多------不需要维护连接状态,每个包都是独立的。
IpPacket:统一的容器
有了 IpHeader、TcpHeader、UdpHeader 三个解析器,还需要一个统一的容器把它们组装起来。这就是 IpPacket(文件路径 vpn/packet/IpPacket.kt):
kotlin
data class IpPacket(
val rawBytes: ByteArray,
val ipHeader: IpHeader,
) {
val tcpHeader: TcpHeader?
get() = if (ipHeader.isTcp)
TcpHeader(rawBytes, ipHeader.headerLength, ipHeader.headerLength)
else null
val udpHeader: UdpHeader?
get() = if (ipHeader.isUdp)
UdpHeader(rawBytes, ipHeader.headerLength)
else null
val payload: ByteArray
get() {
val transportHeaderLength = when {
ipHeader.isTcp -> tcpHeader!!.headerLength
ipHeader.isUdp -> 8
else -> 0
}
val payloadStart = ipHeader.headerLength + transportHeaderLength
return if (payloadStart < ipHeader.totalLength.coerceAtMost(rawBytes.size)) {
rawBytes.copyOfRange(payloadStart,
ipHeader.totalLength.coerceAtMost(rawBytes.size))
} else {
ByteArray(0)
}
}
companion object {
fun parse(data: ByteArray, length: Int = data.size): IpPacket? {
if (length < 20) return null
val bytes = if (length < data.size) data.copyOf(length) else data
val header = IpHeader(bytes)
if (header.version != 4) return null
if (header.headerLength < 20) return null
if (header.totalLength < header.headerLength) return null
if (header.totalLength > bytes.size) return null
if (header.isTcp && bytes.size < header.headerLength + 20) return null
if (header.isUdp && bytes.size < header.headerLength + 8) return null
return IpPacket(bytes, header)
}
}
}
parse() 方法做了一系列防御性校验:长度至少 20 字节(IP 头最小值)、版本号必须是 4、totalLength 必须合理、如果是 TCP 则 buffer 必须能容纳至少 20 字节的 TCP 头、如果是 UDP 则至少 8 字节。任何一项不满足就返回 null,上层调用者直接跳过这个包。
为什么 tcpHeader 和 udpHeader 用懒加载(get())而不是在构造时解析?因为不是每个 IpPacket 都需要访问传输层头部。比如在丢包操纵器里,只需要知道这是一个 TCP 包就够了,不需要解析 TCP 头部的每个字段。懒加载避免了不必要的解析开销。
IpPacket 同时持有 rawBytes 和解析后的结构化数据。这样做是因为后续操纵数据包时(篡改、重发),需要在原始字节上直接修改,然后重新解析。withRawBytes() 方法就是干这个的:用新字节创建一个新的 IpPacket,自动重新解析所有头部。
IP 分片重组:把碎片拼回去
这是数据包解析中最容易被忽略、但出 bug 最难查的部分。
为什么会有分片? MTU(Maximum Transmission Unit)限制了单个 IP 包的最大尺寸,通常是 1500 字节。如果一个 UDP 包的载荷超过 MTU 减去 IP 头的长度(1480 字节),内核的 IP 层会把它拆成多个分片(Fragment),每个分片作为一个独立的 IP 包到达 TUN 接口。
这意味着,当 App 发送一个 3000 字节的 UDP 报文时,你在 TUN 接口读到的不是一个包,而是两个:第一个 1480 字节载荷,第二个约 1520 字节载荷。如果你只处理第一个分片就往上游发,对方会收到一个不完整的 UDP 报文。
分片的标识方式 。同一个原始包的所有分片共享三个字段:源 IP 、目的 IP 、标识号(identification)。内核为每个原始包分配一个唯一的 identification,分片和原始包的值相同。此外,每个分片还有两个关键字段:
- MF(More Fragments)标志 :
MF=1表示后面还有分片,MF=0表示这是最后一个分片。 - 分片偏移(Fragment Offset) :这个分片的载荷在原始包载荷中的位置,单位是 8 字节。第一个分片的 offset=0,第二个可能是 185(1480/8=185)。
重组逻辑分为五步:
- 检查 IP 头的 flags 字段。如果
MF=0且fragmentOffset=0,说明这不是分片包,直接处理。否则进入重组流程。 - 以(源 IP + 目的 IP + identification + protocol)为 key,找到或创建一个重组缓冲区。
- 第一个分片(
offset=0)携带原始 IP 头,保存起来用于最终重组。后续分片的 IP 头是分片本身的头,不能直接用。 - 最后一个分片(
MF=0)的offset + 载荷长度等于原始包的总载荷长度。此时我们知道完整包应该有多大。 - 所有分片到齐后,按 offset 排列,拼接载荷,重建 IP 头(更新
totalLength、清除分片标志、重算校验和)。
WeakNet 的 IpFragmentReassembler(文件路径 vpn/packet/IpFragmentReassembler.kt)完整实现了这个逻辑。关键代码片段:
kotlin
// 判断是否为分片包
val flagsAndOffset = ((data[6].toInt() and 0xFF) shl 8) or (data[7].toInt() and 0xFF)
val mf = (flagsAndOffset and 0x2000) != 0 // More Fragments 标志
val fragOffset = (flagsAndOffset and 0x1FFF) * 8 // 分片偏移,转为字节
// 非分片包直接返回
if (!mf && fragOffset == 0) return data.copyOf(length)
// 以四元组为 key
val key = FragKey(srcIp, dstIp, identification, protocol)
// 首个分片保存原始 IP 头
if (fragOffset == 0) {
buffer.ipHeader = data.copyOfRange(0, ihl)
buffer.ipHeaderLength = ihl
}
// 存储分片载荷
buffer.chunks[fragOffset] = data.copyOfRange(ihl, ihl + payloadLen)
// 最后一个分片确定总载荷长度
if (!mf) {
buffer.totalPayloadLen = fragOffset + payloadLen
}
检查是否所有分片到齐:
kotlin
val sorted = buffer.chunks.toSortedMap()
var expectedOffset = 0
for ((fragOff, chunk) in sorted) {
if (fragOff != expectedOffset) return null // 有间隙,还没齐
expectedOffset = fragOff + chunk.size
}
if (expectedOffset != buffer.totalPayloadLen) return null // 总长度不对
超时机制:如果 5 秒内没有收齐所有分片,直接丢弃。这是因为分片可能在传输过程中丢失,永远等不到。如果不设超时,缓冲区会无限膨胀,最终 OOM。代码中每秒执行一次清理,遍历所有缓冲区,踢掉超过 5 秒的条目。同时限制最大缓冲区数量为 256 个,超出时淘汰最旧的。
重组后的善后工作 :拼接好的数据包需要更新三个地方------totalLength 改为实际总长度、清除 MF 标志和分片偏移(设为 0)、重算 IP 头校验和。忘记任何一个都会导致下游解析出错。
校验和:最后一个字节也不能算错
网络协议的校验和是一种简单但有效的完整性校验机制。在用户态构造或修改数据包时,必须正确计算校验和,否则数据包会被协议栈静默丢弃------没有错误信息,没有日志,就是收不到。
IP 头校验和只校验 IP 头部本身(不含载荷)。算法很经典:
- 把校验和字段(第 10-11 字节)清零
- 把 IP 头部所有 16 位字(每 2 字节一个)累加为一个 32 位整数
- 把进位折叠回来:
sum = (sum and 0xFFFF) + (sum shr 16),重复直到没有进位 - 取反:
result = sum.inv() and 0xFFFF
WeakNet 中的实现(文件路径 util/ByteUtils.kt):
kotlin
fun calculateChecksum(bytes: ByteArray, offset: Int, length: Int): Int {
var sum = 0L
var i = 0
while (i < length) {
val word = if (i + 1 < length) {
((bytes[offset + i].toInt() and 0xFF) shl 8) or
(bytes[offset + i + 1].toInt() and 0xFF)
} else {
(bytes[offset + i].toInt() and 0xFF) shl 8 // 奇数长度的末尾补零
}
sum += word.toLong()
i += 2
}
while (sum shr 16 != 0L) {
sum = (sum and 0xFFFF) + (sum shr 16)
}
return (sum.inv() and 0xFFFF).toInt()
}
TCP/UDP 校验和 比 IP 校验和多了一步------它们使用伪首部(Pseudo Header)。伪首部不是数据包中真实存在的数据,而是一个 12 字节的虚拟结构,包含源 IP、目的 IP、协议号和 TCP/UDP 长度:
text
伪首部 (12 bytes):
┌──────────────────┐
│ 源 IP (4字节) │
├──────────────────┤
│ 目的 IP (4字节) │
├──────┬─────┬─────┤
│ 零 │协议 │长度 │
│(1字节)│(1) │(2) │
└──────┴─────┴─────┘
计算时,把伪首部 + TCP/UDP 头部 + 载荷拼接在一起,然后用同样的累加-折叠-取反算法。伪首部的作用是让校验和覆盖 IP 地址信息,防止数据包被错误路由后仍通过校验。
一个坑 :UDP 允许校验和填 0,表示"不校验"。但在 Android 上,填 0 会导致 UDP 包被静默丢弃。Android 的网络栈对 UDP 校验和的校验比 Linux 桌面更严格。在 WeakNet 的早期版本中,DNS 响应包有时能被 App 收到有时收不到,排查了半天发现就是 UDP 校验和没算。从此以后,所有 UDP 包必须计算真实校验和。
修改数据包时也必须重算校验和。TamperManipulator 篡改载荷的一个字节后,需要依次更新 IP 头校验和和 TCP/UDP 校验和。IpFragmentReassembler 重组后也要重算 IP 头校验和。这些地方漏掉任何一处,数据包都会变成"幽灵包"------发出了但没人认。
写在最后
从字节流到结构化数据,这就是 IP 数据包解析的全部。20 字节的 IP 头、20 字节的 TCP 头(最小值)、8 字节的 UDP 头------加起来不到 50 个字节,却承载了整个互联网的寻址和传输逻辑。每一个 bit 都有明确含义,每一个偏移量都由 RFC 精确定义,没有任何冗余。
WeakNet 的解析器设计有几个值得借鉴的点:用 offset 而非切片避免内存拷贝、and 0xFF 处理 Kotlin 有符号字节、防御性校验在构造时就过滤异常数据、懒加载按需解析传输层头部。这些都是从实际调试中总结出来的------教科书不会告诉你 Kotlin 的 Byte 是有符号的,也不会告诉你 Android 会丢弃 UDP 校验和为 0 的包。
下一篇是整个系列最硬核的部分:用户态 TCP 代理。我们将从 SYN 到 FIN,完整实现一个 TCP 状态机。为什么 SYN-ACK 要延迟发送?为什么数据包要先 ACK 再进管线?为什么 RST 的序列号必须在窗口范围内?这些问题都将在下一篇得到回答。