在上一章 事件驱动服务 (Event Service) 中,我们要么通过 enet_host_service 将信件交给邮递员,要么从邮递员那里接过信件。
但是,你有没有想过,当你把一个简单的字符串 "Hello" 交给 ENet 时,它在网线上到底变成了什么样子?它怎么知道这段数据是发给谁的?它怎么知道这数据是不是丢了?
本章我们将揭开 ENet 的"黑盒",探索它的协议处理逻辑。这是让不可靠的 UDP 变得像 TCP 一样可靠的魔法所在。
1. 为什么要处理协议?
核心问题 :原始的 UDP 数据包就像一张没有任何标记的白纸。如果你在两台电脑之间直接发送 "Hello":
- 接收方不知道这仅仅是聊天信息,还是一个断开连接的命令。
- 接收方不知道这个包是第几个发的(顺序问题)。
- 如果数据太长超过了网络限制(MTU),UDP 会直接丢弃或在底层分片导致效率低下。
ENet 的解决方案 : ENet 定义了一套严格的语法规则(Protocol)。它给你的数据穿上一层"制服"(Header),并在数据前面加上"指令说明"(Command)。
生活案例: 这就好比你寄快递。你不能直接把东西扔进卡车。你需要:
- 打包:把东西装进箱子(封装数据)。
- 贴单:写上发件人、收件人、时间(Header)。
- 标记:贴上"易碎品"、"必须签收"或"放在门口即可"(Command Flags)。
2. 核心概念:ENet 的数据结构
ENet 的协议由几个核心部分组成,它们像积木一样拼装在一起。
2.1 协议头 (Protocol Header)
每一个 ENet 发出的 UDP 包,最前面一定是一个固定的头部。它就像信封上的基础信息。
c
// file: protocol.h (简化)
typedef struct _ENetProtocolHeader
{
enet_uint16 peerID; // 对方的 ID (包含一些标志位)
enet_uint16 sentTime; // 发送的时间戳
} ENetProtocolHeader;
- PeerID:告诉接收方,我是谁。
- SentTime:用于计算网络延迟(Ping 值/RTT)。
2.2 命令 (Commands)
在头部之后,紧跟着一个或多个"命令"。你的数据(Payload)只是命令的一部分。 常见的命令类型包括:
- ACKNOWLEDGE: "我收到你的包了!"
- CONNECT: "我想和你建立连接。"
- SEND_RELIABLE: "这是一段必须送达的数据。"
- PING: "测测网速。"
2.3 字节序 (Endianness)
不同的 CPU 存储数字的方式不同(有的从左读,有的从右读)。
- 主机字节序 (Host): 你的电脑怎么存。
- 网络字节序 (Net): 互联网统一标准(大端序)。
ENet 会自动处理转换,确保你用 Intel 电脑发的包,手机(ARM 架构)也能读懂。你会经常在代码里看到 ENET_HOST_TO_NET_16 这样的宏。
3. 内部原理解析:打包与发送
当你在服务循环中(enet_host_service)触发发送时,ENet 会执行一系列操作将你的命令打包。
3.1 流程图解
3.2 深入代码:命令聚合
ENet 的一个高效特性是命令聚合。它不会每发一个命令就调用一次 Socket 发送,而是尽可能把多个命令塞进同一个 UDP 包里,直到填满为止。
让我们看看 protocol.c 中的简化逻辑:
c
// file: protocol.c (逻辑示意)
// 遍历所有待发送的命令
while (currentCommand != endOfQueue) {
// 检查:如果把当前命令加进去,会不会超过最大包大小 (MTU)?
if (packetSize + commandSize > peer->mtu) {
break; // 装不下了,这就发出去,剩下的下次发
}
// 将命令放入发送缓冲区
buffer->data = command;
packetSize += commandSize;
// 如果命令携带了用户数据 (Packet),把数据也加进去
if (command->packet != NULL) {
// 添加数据部分
}
}
// 最后调用底层的 socket 发送
enet_socket_send(socket, address, buffers, count);
3.3 深入代码:处理接收 (Reliability)
ENet 如何保证可靠性?通过 ACK (确认回执) 机制。 当接收端收到一个 RELIABLE 包时,它必须 发送一个 ACK 命令回去。
让我们看看接收端是如何处理 SEND_RELIABLE 命令的:
c
// file: protocol.c (简化)
case ENET_PROTOCOL_COMMAND_SEND_RELIABLE:
// 1. 解析命令,拿到数据
// ... (代码略)
// 2. 放入接收队列,等待用户读取
enet_peer_queue_incoming_command (peer, ...);
// 3. 关键:如果命令带有 ACK 标志,准备发送确认回执
if (command->header.command & ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE) {
// 在发送队列里插入一个 ACK 命令
enet_peer_queue_acknowledgement (peer, command, sentTime);
}
break;
发送端那边,如果不收到这个 ACK,它会在 check_timeouts 函数中检测到超时,并重新发送原来的命令。
4. 进阶机制:分片 (Fragmentation)
在 数据包封装 (ENetPacket) 中,我们提到可以发送很大的数据包。但以太网通常限制每个包不能超过约 1500 字节(MTU)。ENet 是如何做到的?
原理: ENet 会自动把你的大数据"切"成小块(Fragment),分别发送。
c
// file: protocol.c (send_fragment 逻辑示意)
// 如果数据太大...
if (totalLength > peer->mtu) {
// 计算需要切成几块
int fragmentCount = (totalLength + mtu - 1) / mtu;
// 循环发送每一块
for (i = 0; i < fragmentCount; i++) {
// 创建一个特殊的命令: SEND_FRAGMENT
command.fragmentNumber = i;
command.totalLength = totalLength;
// 发送这一小块
}
}
在接收端,ENet 会把这些碎片缓存起来,直到所有碎片都到齐了 ,才把它们拼成一个完整的 ENetPacket 交给用户。这对用户是完全透明的!
5. 常见问题与实战意义
Q: 既然 ENet 帮我处理了协议,我还需要关心这些吗? A: 虽然不需要手动写协议代码,但理解它有助于优化:
- 带宽控制:你知道每个包都有头部开销,所以把 100 个 1 字节的小包合并成 1 个大包发送(在应用层合并),比让 ENet 发 100 个 UDP 包要高效得多。
- MTU 限制:虽然 ENet 支持分片,但分片丢包率更高(一块丢了,整体都要重传)。尽量让你的包小于 1400 字节是最佳实践。
Q: 为什么我的 Ping 值显示在 Header 里? A: Header 里的 sentTime 允许接收方计算数据在路上跑了多久。这对于游戏同步至关重要。
6. 总结
在本章中,我们深入了 ENet 的引擎室,了解了:
- 协议结构:Header + Commands + Data。
- 字节序处理:ENet 自动处理了不同硬件平台的兼容性。
- 命令聚合:多个命令搭同一班车出发,节省带宽。
- 可靠性与分片:底层的 ACK 机制和大数据切割逻辑。
至此,我们已经理解了 ENet 的大部分软件逻辑。但是,这一切最终都要通过操作系统发送到物理网络上。不同的操作系统(Windows, Linux, macOS)操作网络的方式都不一样。ENet 是如何做到一套代码跑遍天下的呢?