在 ENet 中,我们不直接发送散乱的字节流,而是将数据封装在数据包 (ENetPacket) 中。
1. 为什么需要 ENetPacket?
想象一下,你在玩一个快节奏的射击游戏:
- 场景 A :你开了一枪。这个信息必须到达服务器,否则你打中人都没伤害。
- 场景 B:你的角色在移动。如果丢失了一两个位置更新包,服务器可以用下一个包修正位置,问题不大。
如果直接使用原始的 UDP 协议,它就像寄平信,既容易丢,也不保证顺序。 ENetPacket 的出现就是为了给这些数据贴上"标签"。它允许你告诉 ENet:"这封信非常重要,必须送到(Reliable)"或者"这封信丢了也没关系(Unreliable)"。
2. 核心概念:可靠性与标志
在创建数据包时,最重要的参数是 flags(标志位)。它决定了数据包的传输行为。
2.1 可靠数据包 (Reliable)
- 标志 :
ENET_PACKET_FLAG_RELIABLE - 行为:就像 TCP 协议。如果数据包丢失,ENet 会自动重传,直到对方确认收到。且保证按发送顺序到达。
- 适用:聊天信息、购买物品、释放技能。
2.2 不可靠数据包 (Unreliable)
- 标志 :
0(无标志) - 行为:就像原始 UDP。发出去就不管了,丢了就丢了。但 ENet 依然会帮你进行基本的完整性校验(CRC)。
- 适用:频繁的位置更新、语音数据。
2.3 无序数据包 (Unsequenced)
- 标志 :
ENET_PACKET_FLAG_UNSEQUENCED - 行为:数据包不仅不可靠,而且不保证顺序。后面的包可能比前面的先到。
- 适用:极端追求低延迟且对旧数据完全不感兴趣的场景。
3. 实战:创建与发送
让我们通过代码来看看如何封装并发送一个简单的字符串消息。
第一步:准备数据与创建包
c
#include <string.h>
// ... 初始化代码略 ...
const char * message = "Hello, ENet!";
// 创建数据包
// 参数1: 数据内容指针
// 参数2: 数据长度 (记得包含字符串结尾的 \0)
// 参数3: 标志位 (这里我们选择可靠传输)
ENetPacket * packet = enet_packet_create(message,
strlen(message) + 1,
ENET_PACKET_FLAG_RELIABLE);
解释 : 当你调用 enet_packet_create 时,ENet 会在内部申请一块新的内存,并将你的 message 复制 进去。这意味着在这个函数返回后,你可以随意修改或销毁原来的 message 变量,不会影响数据包。
第二步:发送给对等节点
c
// 发送数据包
// 参数1: 目标节点 (peer)
// 参数2: 通道 ID (0) - 稍后在下一章详细讲解
// 参数3: 数据包对象
int result = enet_peer_send(peer, 0, packet);
if (result < 0) {
fprintf(stderr, "发送失败!\n");
// 如果发送失败,你需要自己销毁这个包
enet_packet_destroy(packet);
}
- 关于通道的知识,请参考 多通道机制 (Channels)。
重要规则:
- 如果
enet_peer_send成功(返回 0),ENet 接管了数据包的所有权 。你千万不要再调用 destroy,ENet 会在发完后自动销毁它。 - 如果发送失败,你需要手动销毁它以防内存泄漏。
第三步:接收数据包
在接收端(Event Loop 中),我们解包数据。
c
ENetEvent event;
while (enet_host_service(host, &event, 1000) > 0) {
switch (event.type) {
case ENET_EVENT_TYPE_RECEIVE:
printf("收到消息: %s\n", (char*)event.packet->data);
// 重要:接收到的包必须由用户负责销毁!
enet_packet_destroy(event.packet);
break;
}
}
注意 :ENet 不知道你什么时候用完数据,所以在 ENET_EVENT_TYPE_RECEIVE 事件处理完毕后,你必须 调用 enet_packet_destroy,否则内存会一直被占用。
4. 内部原理解析
当你创建一个数据包时,内存里发生了什么?
4.1 深入代码 (packet.c)
让我们看看 enet_packet_create 的简化版实现,理解它是如何管理内存的。
1. 分配头部 首先,分配 ENetPacket 结构体本身的内存。
c
// file: packet.c (简化)
ENetPacket * enet_packet_create (const void * data, size_t dataLength, enet_uint32 flags)
{
// 1. 分配结构体
ENetPacket * packet = (ENetPacket *) enet_malloc (sizeof (ENetPacket));
if (packet == NULL) return NULL; // 内存不足
2. 分配与复制数据 这是关键点。除非你指定了 ENET_PACKET_FLAG_NO_ALLOCATE(高级用法),否则 ENet 总是会复制数据。
c
// file: packet.c (简化)
// 2. 分配数据缓冲区
packet -> data = (enet_uint8 *) enet_malloc (dataLength);
// 3. 复制用户数据
if (data != NULL)
memcpy (packet -> data, data, dataLength);
3. 设置属性 最后,初始化标志位和引用计数。
c
// file: packet.c (简化)
packet -> flags = flags;
packet -> dataLength = dataLength;
packet -> referenceCount = 0; // 引用计数用于多播等场景
return packet;
}
4.2 校验和 (CRC32)
虽然在简单的创建函数中看不到,但在发送前,ENet 还会计算数据的 CRC32 校验和。这保证了如果网络传输中数据位发生了翻转(比如比特流干扰),ENet 能够识别出来并丢弃坏包,而不会把乱码交给你的程序。
5. 高级技巧:避免内存复制
对于极其追求性能的场景,你可能希望避免 memcpy 的开销。 你可以使用 ENET_PACKET_FLAG_NO_ALLOCATE 标志。
c
// 假设 my_data 是你自己管理的一块持久内存
ENetPacket * packet = enet_packet_create(my_data, len,
ENET_PACKET_FLAG_RELIABLE | ENET_PACKET_FLAG_NO_ALLOCATE);
警告 :使用此标志时,ENet 只会保存指针 my_data。你必须确保在 ENet 发送完毕并销毁 packet 之前,这块内存一直有效。这通常用于发送静态常量数据或通过自定义内存池管理的数据。
6. 总结
在本章中,我们学习了 ENet 通信的基本单元:
- ENetPacket 是数据的载体,包含数据内容和传输标志。
- 通过 Flags,我们可以轻松切换 TCP 风格的可靠传输和 UDP 风格的快速传输。
- 生命周期管理 至关重要:
- 创建时:数据被复制。
- 发送成功后:ENet 负责销毁。
- 接收后:用户负责销毁。
现在我们已经能发包了,但如果我们需要同时处理聊天信息和高频的位置同步,如何避免聊天信息阻塞了位置更新呢?这就引入了 ENet 的另一个强大特性------通道。