KCP源码解析 (7 终结) KCP 数据段 (IKCPSEG)

6 阅读9分钟

在上一章 拥塞控制 中,我们探讨了 KCP 如何像一位智能司机一样,根据网络路况动态调整自己的“车速”。我们了解到,KCP 通过分析 ACK、RTT 和丢包情况来做出这些智能决策。

这引出了一个根本性的问题:KCP 是如何通过一个个原始的数据包来传递这些丰富的信息的?无论是业务数据、确认信号(ACK),还是窗口大小通知,它们在网络中传输时,究竟长什么样?

本章,我们将深入 KCP 协议的最底层,解剖它的基本组成单元——IKCPSEG,也就是 KCP 的数据段。这正是 KCP 所有魔法赖以实现的“基因密码”。

问题的提出:一个万能的“快递包裹”

让我们再次回到那个熟悉的“快递”比喻。当你在 KCP 的世界里寄送任何东西时,无论是写给朋友的一封信(用户数据),还是寄给快递公司的一张签收回执(ACK),你都需要一个标准的“快递包裹”。

这个包裹本身必须携带足够的信息,以便沿途的每个处理中心(发送方、接收方)都能明白:

  • 这个包裹是寄给谁的?(会话 ID)
  • 包裹里装的是什么类型的物品?是普通货物还是紧急文件?(命令类型)
  • 这是系列包裹中的第几个?(序列号)
  • 它是什么时候寄出的?(时间戳)
  • 寄件人当前能接收多少回寄的包裹?(窗口大小)

IKCPSEG 结构体,就是 KCP 世界里这个标准的、万能的“快递包裹”。KCP 协议的所有功能,都建立在对这些“包裹”的打包、发送、接收和拆解之上。

IKCPSEG 结构体:解剖“快递面单”

IKCPSEG 是一个在 ikcp.h 中定义的 C 语言结构体。它不仅包含了要传输的数据本身(“货物”),更重要的是,它包含了大量的控制信息(“面单”)。

让我们来看看它的定义,并把它拆解成几个功能区域来理解:

// ikcp.h: IKCPSEG 结构体定义
struct IKCPSEG
{
	struct IQUEUEHEAD node; // 用于将数据段链入各种队列
	IUINT32 conv;           // 会话 ID,标识连接
	IUINT32 cmd;            // 命令字 (Command)
	IUINT32 frg;            // 分片号 (Fragment)
	IUINT32 wnd;            // 剩余接收窗口大小 (Window)
	IUINT32 ts;             // 时间戳 (Timestamp)
	IUINT32 sn;             // 序列号 (Sequence Number)
	IUINT32 una;            // 未确认的最小序列号 (Unacknowledged)
	IUINT32 len;            // 数据长度
	IUINT32 resendts;       // 重传时间戳
	IUINT32 rto;            // 重传超时时间
	IUINT32 fastack;        // 快速重传触发器
	IUINT32 xmit;           // 发送次数
	char data[1];           // 数据指针
};

初看之下字段很多,但别担心,我们可以把网络传输时真正需要编码到“面单”上的核心字段分为几类:

1. 身份与指令 (conv, cmd)

  • conv (会话 ID): 这是连接的唯一标识,就像快递单上的收件人和寄件人地址,确保包裹不会送错。我们在 KCP 控制块 (ikcpcb) 中已经知道,通信双方的 conv 必须一致。
  • cmd (命令): 这告诉接收方这个包裹的“类型”是什么。最常见的类型有:
    • IKCP_CMD_PUSH (81): 这是一个数据包,里面装着应用层的数据。
    • IKCP_CMD_ACK (82): 这是一个确认包,告诉对方“我收到了你的某个包裹”。
    • IKCP_CMD_WASK (83): 这是一个窗口探测请求,询问对方“你还能收包裹吗?”。
    • IKCP_CMD_WINS (84): 这是一个窗口大小通知,告诉对方“我现在能收多少包裹”。

2. 排序与重组 (sn, frg)

  • sn (序列号): 这是 KCP 实现有序传输的基石。每个数据段都会被赋予一个独一无二、单向递增的 sn。接收方根据 sn 来排序乱序的包。
  • frg (分片号): 当一个大的数据块被 ikcp_send 切分成多个小数据段时,frg 用来标记这是第几个分片(倒序计数)。例如,一个包被切成 3 片,它们的 frg 分别是 2, 1, 0。接收方看到 frg=0 就知道这是最后一个分片,一条完整的消息接收完毕。

3. 可靠性与流量控制 (una, wnd, ts)

  • una (未确认的最小序号): 这个字段非常重要,它告诉对方:“所有序列号小于 una 的包我都已经收到了”。发送方收到后,就可以清理自己发送缓冲区里那些已经被确认的包。
  • wnd (窗口大小): 这个字段告诉对方:“我本地的接收队列还剩下多少空位”。这是实现流量控制的关键,发送方会根据这个值来调整发送速度,避免把接收方撑爆。
  • ts (时间戳): 这个字段记录了数据段被发送时的本地时间。当接收方回复 ACK 时,会把这个时间戳原样带回。发送方用当前时间减去这个 ts,就能精确计算出 RTT(往返时间),这是 拥塞控制的核心数据。

4. 数据负载 (len, data)

  • len: 记录了 data 部分的长度。
  • data: 这就是真正的“货物”——应用层要发送的数据。

注意:像 resendts, rto, fastack, xmit 这些字段是 KCP 内部管理数据段状态时使用的,它们不会被编码到网络包里。它们是存在于内存中,帮助 ikcp_flush 决定何时重传、如何计算 RTO 等。

从结构体到字节流:包裹的打包过程

IKCPSEG 结构体只存在于内存中。当 KCP 决定要发送一个数据段时,它需要将这些“面单”信息和“货物”打包成一个二进制字节流,以便通过 UDP 发送。这个过程由 ikcp_encode_seg 函数完成。

// ikcp.c: ikcp_encode_seg 简化逻辑
// 将一个 IKCPSEG 结构体的信息编码到 ptr 指向的缓冲区
static char *ikcp_encode_seg(char *ptr, const IKCPSEG *seg)
{
	ptr = ikcp_encode32u(ptr, seg->conv); // 编码会话 ID
	ptr = ikcp_encode8u(ptr, (IUINT8)seg->cmd);  // 编码命令
	ptr = ikcp_encode8u(ptr, (IUINT8)seg->frg);  // 编码分片号
	ptr = ikcp_encode16u(ptr, (IUINT16)seg->wnd); // 编码窗口大小
	ptr = ikcp_encode32u(ptr, seg->ts);          // 编码时间戳
	ptr = ikcp_encode32u(ptr, seg->sn);          // 编码序列号
	ptr = ikcp_encode32u(ptr, seg->una);         // 编码 una
	ptr = ikcp_encode32u(ptr, seg->len);         // 编码数据长度
	return ptr;
}

可以看到,这个函数就是按照固定的顺序,将 IKCPSEG 的各个核心字段写入到一个连续的内存(kcp->buffer)中。写入头部之后,ikcp_flush 会接着把 seg->data 的内容拷贝到后面。这样,一个完整的、可以在网络上传输的 KCP 数据包就准备好了。

这个固定的格式,我们称之为“协议头”。KCP 的协议头固定为 24 字节。

网络中的 KCP 数据包布局

让我们用一个图来直观地看看一个 KCP 数据包在网络中传输时的样子。

graph TD
    subgraph "KCP 数据段 (在网络中传输的字节流)"
        direction LR
        A["conv (4字节)"] --> B["cmd (1字节)"]
        B --> C["frg (1字节)"]
        C --> D["wnd (2字节)"]
        D --> E["ts (4字节)"]
        E --> F["sn (4字节)"]
        F --> G["una (4字节)"]
        G --> H["len (4字节)"]
        H --> I["data (len 字节)"]
    end
    subgraph " "
      direction LR

      subgraph "KCP 负载 (长度可变)"
          I
      end
    end

当 底层数据输入处理 (ikcp_input) 函数收到这样一个字节流时,它会执行完全相反的解码操作,从字节流中逐一解析出 conv, cmd 等信息,再重新构建出一个内存中的 IKCPSEG 结构体,交给 KCP 内部的逻辑去处理。

数据段的生命周期回顾

现在我们了解了 IKCPSEG 的内部构造,就可以更清晰地回顾它在 KCP 系统中的一生了:

  1. 诞生: 当应用调用 ikcp_send 时,KCP 会创建 IKCPSEG 对象,并将应用数据拷贝到 seg->data 中,然后将其放入 snd_queue。此时,sn, ts 等字段还是空的。
  2. 成年: 在 ikcp_flush 中,数据段从 snd_queue 移入 snd_buf。KCP 为它分配了 sn, ts 等关键信息,它“成年”了。
  3. 远行: ikcp_flush 调用 ikcp_encode_seg 将其编码成字节流,通过 output 函数发送到网络上。
  4. 抵达与重生: 在接收端,ikcp_input 函数解码字节流,在内存中“重生”出一个一模一样的 IKCPSEG 对象。
  5. 使命完成: 接收端根据这个 IKCPSEG 的信息,更新自己的状态(如发送 ACK),并将数据放入 rcv_bufrcv_queue。数据被 ikcp_recv 读取后,这个 IKCPSEG 对象最终被释放,完成它的使命。

总结

在本章中,我们解剖了 KCP 协议的原子单位——IKCPSEG 数据段。

  • IKCPSEG 是 KCP 中数据传输的基本单位,它像一个“快递包裹”,包含了“面单”(头部信息)和“货物”(数据)。
  • 它的头部字段,如 conv, cmd, sn, una, wnd, ts 等,是 KCP 实现所有高级功能(可靠性、有序性、流量控制、拥塞控制)的数据基础。
  • KCP 通过 ikcp_encode_seg 和相应的解码函数,在内存中的 IKCPSEG 结构体和网络中的字节流之间进行转换。
  • KCP 的协议头固定为 24 字节,这使得 KCP 相比其他复杂协议更加轻量。

系列教程总结

恭喜你!至此,你已经完成了 KCP 核心原理的整个探索之旅。让我们回顾一下走过的路:

  1. 我们从顶层的 **用户接口 (ikcp_send / ikcp_recv) 开始,了解了如何与 KCP 交互。
  2. 接着深入其“大脑” KCP 控制块 (ikcpcb),知道了 KCP 在哪里存储所有状态。
  3. 我们探索了 KCP 的“心跳” 核心更新与刷新机制 (ikcp_update & ikcp_flush),明白了 KCP 是如何主动工作的。
  4. 我们参观了它的“物流系统” 数据流转与队列管理,看清了数据在内部的流转路径。
  5. 我们守在“收件口” 底层数据输入处理 (ikcp_input),观察了 KCP 如何处理来自网络的原始数据。
  6. 我们学习了它智能的 拥塞控制 算法,理解了它为何能做到又快又稳。
  7. 最后,我们解剖了最基本的 KCP 数据段 (IKCPSEG),揭示了所有功能的实现基石。

希望这个系列教程能帮助你从一个 KCP 的使用者,变成一个真正理解其内在美的开发者。有了这些知识,你将能更好地使用 KCP,甚至在遇到问题时,能够深入源码进行调试和优化。

编程世界的探索永无止境,愿你享受其中的乐趣!