KCP源码解析 (1) 用户接口 (ikcp_send / ikcp_recv)

143 阅读9分钟

欢迎来到 KCP 的世界!KCP 是一个快速、可靠的传输协议,旨在解决网络游戏、实时音视频等场景下 TCP 延迟过高的问题。本系列教程将通过源码分析,带你深入浅出地了解 KCP 的内部工作原理。

作为我们的第一站,我们先从与应用开发者息息相关的部分开始:如何使用 KCP 来收发数据。

问题的提出:简单地收发可靠消息

想象一下,你正在开发一款在线聊天应用。你希望客户端 A 发送的一句“你好”能够完整按顺序地被客户端 B 收到,即使网络偶尔会丢包或出现数据包乱序。

直接使用 UDP 太过原始,你需要自己处理丢包重传、消息排序等一系列复杂问题。而使用 TCP,虽然可靠,但在某些网络环境下延迟可能无法接受。

KCP 正是为了解决这个问题而生。它提供了一套简洁的接口,让你能像使用 TCP 一样方便地发送可靠数据,同时享受比 TCP 更低的延迟。而这套接口的核心,就是我们本章要介绍的 ikcp_sendikcp_recv

核心接口:你的“发件箱”与“收件箱”

我们可以把 KCP 的这两个核心函数想象成一对智能的“发件箱”和“收件箱”。

  • ikcp_send: 这是你的发件箱。你只需要把想发送的数据(比如聊天消息“你好”)扔进去,KCP 就会帮你处理剩下的一切:打包、编号,并准备发送。
  • ikcp_recv: 这是你的收件箱。当 KCP 收到对方发来的数据,并经过它内部复杂的处理(比如去重、排序、重组)后,会把一条条完整的、可以被你直接使用的消息放在这里,等你来取。

这两个函数为你屏蔽了底层所有复杂的细节,让你的应用层代码可以保持得非常整洁。

发送数据:ikcp_send

ikcp_send 函数的原型定义在 ikcp.h 中:

// user/upper level send, returns below zero for error
int ikcp_send(ikcpcb *kcp, const char *buffer, int len);

它的参数非常直观:

  • kcp: 这是一个指向 ikcpcb 结构体的指针,你可以把它理解为 KCP 连接的“大脑”或“控制中心”。它包含了这次通信的所有状态信息。我们将在下一章 KCP 控制块 (ikcpcb) 中详细介绍它。
  • buffer: 你想要发送的数据内容。
  • len: 你想发送数据的长度。

让我们用一个简单的例子来发送消息 "你好,KCP!":

// 假设 kcp 对象已经被正确创建和配置
const char* message = "你好,KCP!";
// 注意:strlen 不计算末尾的 '\0'
int length = strlen(message);

// 调用 ikcp_send 将数据放入“发件箱”
int ret = ikcp_send(kcp, message, length);

if (ret < 0) {
    // 发生错误,例如数据长度过大
    printf("发送失败!错误码: %d\n", ret);
}

重点:调用 ikcp_send 成功,并不意味着数据已经通过网络发给了对方。它仅仅表示 KCP 已经接收了你的数据,并把它放进了内部的“发送队列”里。真正的网络发送,是由 KCP 的其他内部机制驱动的。

接收数据:ikcp_recv

ikcp_recv 函数的原型同样定义在 ikcp.h 中:

// user/upper level recv: returns size, returns below zero for EAGAIN
int ikcp_recv(ikcpcb *kcp, char *buffer, int len);

它的参数也很好理解:

  • kcp: 同上,是同一个 KCP 连接的“大脑”。
  • buffer: 你提供的一块内存空间(缓冲区),用来存放接收到的数据。
  • len: 你提供的缓冲区的大小,防止数据溢出。

现在,我们看看如何从“收件箱”中取出消息:

// 准备一个足够大的缓冲区来接收数据
char receive_buffer[256];

// 尝试从 KCP 的“收件箱”中取出一条完整的消息
int size = ikcp_recv(kcp, receive_buffer, sizeof(receive_buffer));

if (size > 0) {
    // 成功接收到数据,size 是数据的实际长度
    receive_buffer[size] = '\0'; // 手动添加字符串结束符,便于打印
    printf("收到消息: %s\n", receive_buffer);
} else if (size == -1) {
    // “收件箱”是空的,还没有完整的消息可以取出
} else {
    // 其他错误,例如缓冲区太小 (size == -3)
    printf("接收错误!错误码: %d\n", size);
}

重点ikcp_recv 只会返回给你完整且有序的消息。如果 KCP 只收到了消息的一部分,或者收到的消息不是当前期望的顺序,ikcp_recv 会返回 -1,告诉你“收件箱”里还没有准备好可以取走的信件。

内部探秘:数据是如何流转的?

了解了如何使用,你可能会好奇:调用 ikcp_send 后,KCP 内部到底发生了什么?

ikcp_send 的幕后工作

当你调用 ikcp_send 时,KCP 并不会立即把你的数据封装成一个 UDP 包发出去。它会执行以下几个步骤:

  1. 数据分片:如果你的数据太大(超过了 mss(Max Segment Size),即最大分段大小),KCP 会像切蛋糕一样,把它切成一个个更小的数据块。
  2. 创建数据段:每个数据块都会被包装成一个 KCP 的内部结构,叫做 KCP 数据段 (IKCPSEG)。这个结构体包含了数据本身,以及序号、时间戳等控制信息。
  3. 放入队列:所有这些新创建的数据段,会被依次放入一个叫做 snd_queue(发送队列)的链表中。

我们可以用一个简单的流程图来展示这个过程:

sequenceDiagram
    participant A as 应用层
    participant S as ikcp_send
    participant Q as 发送队列 (snd_queue)

    A->>S: 调用 ikcp_send("Hello,KCP......!" 10K)
    S->>S: 数据太大?进行分片
    S->>S: 为每个分片创建 IKCPSEG 结构
    S->>Q: 将 IKCPSEG 逐个添加到 snd_queue

让我们看看 ikcp.c 中的简化版核心代码:

// ikcp.c: ikcp_send 核心逻辑简化
int ikcp_send(ikcpcb *kcp, const char *buffer, int len)
{
    // ... 省略流模式和错误检查 ...

    // 1. 计算需要多少个分片
    int count;
    if (len <= (int)kcp->mss) count = 1;
    else count = (len + kcp->mss - 1) / kcp->mss;

    // ... 省略超大包检查 ...
    
    // 2. 遍历并创建每个分片
    for (int i = 0; i < count; i++) {
        int size = len > (int)kcp->mss ? (int)kcp->mss : len;
        IKCPSEG *seg = ikcp_segment_new(kcp, size); // 2a. 创建一个数据段

        memcpy(seg->data, buffer, size); // 2b. 拷贝数据
        seg->len = size;
        seg->frg = count - i - 1; // 标记分片信息

        // 3. 将数据段添加到发送队列的末尾
        iqueue_add_tail(&seg->node, &kcp->snd_queue); 
        kcp->nsnd_que++;
        buffer += size;
        len -= size;
    }
    return sent_len;
}

这段代码清晰地展示了分片和入队的逻辑。数据只是被暂存起来,等待一个合适的时机被真正发送出去。关于队列的更多细节,我们将在 数据流转与队列管理 章节深入探讨。

ikcp_recv 的幕后工作

ikcp_recv 的工作相对简单直接:它只检查一个叫做 rcv_queue(接收队列)的地方。

这个 rcv_queue 非常特别,它里面存放的总是已经排好序、并且是完整的消息段。KCP 内部有其他机制(我们将在后续章节讲解)负责从网络接收原始数据包,经过排序、重组后,才把可以被应用层消费的完整消息放入 rcv_queue

所以,ikcp_recv 的任务就是:

  1. 检查队列:看看 rcv_queue 是否为空。
  2. 提取数据:如果队列不为空,就从队列头部开始取出数据段。
  3. 合并分片:如果一个消息被分片了,它会持续取出数据段,直到取完一个完整消息的所有分片。
  4. 拷贝数据:将这些数据段中的数据拷贝到你提供的 buffer 中。
  5. 清理队列:从 rcv_queue 中删除已经被取走的数据段。

ikcp.c 中的简化版核心代码如下:

// ikcp.c: ikcp_recv 核心逻辑简化
int ikcp_recv(ikcpcb *kcp, char *buffer, int len)
{
    // 1. 检查接收队列是否为空
    if (iqueue_is_empty(&kcp->rcv_queue))
        return -1;

    // ... 省略 peek 和缓冲区大小检查 ...

    // 2. 遍历接收队列,合并分片
    int received_len = 0;
    for (p = kcp->rcv_queue.next; p != &kcp->rcv_queue; ) {
        IKCPSEG *seg = iqueue_entry(p, IKCPSEG, node);
        p = p->next;

        // 3. 拷贝数据到用户缓冲区
        memcpy(buffer, seg->data, seg->len);
        buffer += seg->len;
        received_len += seg->len;

        // 4. 从队列中删除已处理的数据段
        iqueue_del(&seg->node);
        ikcp_segment_delete(kcp, seg);
        kcp->nrcv_que--;
        
        // 如果这是最后一个分片 (frg == 0),则一条消息接收完毕
        if (seg->frg == 0)
            break;
    }
    return received_len;
}

可以看到,ikcp_recv 就像一个从传送带上取包裹的工人,它只负责取走已经打包好的成品,而不关心这些成品是如何被制造和送上传送带的。

一个重要的提醒

读到这里,你可能会问:如果 ikcp_send 只是把数据放入队列,那数据究竟是什么时候发出去的?如果 ikcp_recv 只是从一个有序队列里读数据,那乱序、丢包的数据是在哪里被处理的?

答案是:ikcp_updateikcp_input

  • ikcp_input 负责接收来自底层的原始 UDP 数据,并处理其中的 KCP 数据包。
  • ikcp_update 是 KCP 的“心跳”,你需要在一个循环里定期调用它。它会负责检查 snd_queue 并发送数据、处理超时重传、触发确认(ACK)等所有核心逻辑。

ikcp_sendikcp_recv 只是 KCP 与应用层之间的“数据交换站”。真正的魔法发生在 核心更新与刷新机制 (ikcp_update & ikcp_flush) 和 底层数据输入处理 (ikcp_input)中。

总结

在本章中,我们学习了 KCP 最顶层的两个用户接口:

  • ikcp_send:一个简单的“发件箱”,你只需将数据交给它,它会负责分片并放入内部的发送队列 snd_queue
  • ikcp_recv:一个可靠的“收件箱”,你可以从中取出 KCP 已经为你整理好的、完整有序的消息。这些消息存放在 rcv_queue 中。

这两个函数共同构成了一个简洁而强大的 API,让应用开发者可以轻松地实现可靠数据传输,而无需关心底层的复杂性。

现在我们知道了如何收发数据,但这些操作都围绕着一个核心对象 kcp。这个 kcp 到底是什么?它内部又保存了哪些状态呢?下一章,我们将深入探索 KCP 控制块 (ikcpcb),揭开 KCP “控制中心”的神秘面纱。