原文:http://gafferongames.com/networking-for-game-programmers/sending-and-receiving-packets/
Sending and Receiving Packets
介绍
大家好,我是Glenn Fiedler,欢迎阅读我的网上电子书《游戏程序的网络设计》第二章。
在前一章我们讨论了在电脑之间发送数据的选择,并且决定用UDP而不用TCP。我们选择UDP以便我们的数据能够准时到达而不必等待数据包重发。
现在我将给你展示怎么使用UDP来发送和接收数据。
BSD sockets
对于大多数现代平台,你有某种基本的基于BSD套接字的套接字层可用。
BSD套接字使用如下函数操作比如“socket”,“bind”, “sendto” 和“recvfrom”。你当然可以直接使用这些函数,但这样会使你的代码保持平*立性较困难,因为在每个平台这些都会有稍许差别。
所以尽管我首先会给你展示BSD套接字的例子来展示基本的套接字的用法,我们将不会长久地直接使用BSD套接字。反之,我们将封装所有的基本套接字功能,我们会把它们抽象到一个类里面,让你更简单地写出平*立的套接字代码。
Platform specifics
首先,让我们设置一个宏让我们发现我们当前的平台,以便我们在平台间做出细微的改变:
// platform detection
#define PLATFORM_WINDOWS 1
#define PLATFORM_MAC 2
#define PLATFORM_UNIX 3
#if defined(_WIN32)
#define PLATFORM PLATFORM_WINDOWS
#elif defined(__APPLE__)
#define PLATFORM PLATFORM_MAC
#else
#define PLATFORM PLATFORM_UNIX
#endif
现在让我们引入需要的套接字头文件。因为这些头文件也是平台特殊的,我们将使用平台#define来引入不同平台不同的头文件
#if PLATFORM == PLATFORM_WINDOWS
#include <winsock2.h>
#elif PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#endif
套接字构建在基于unix的系统库的标准平台,所以我们不得不作一些额外的连接。然而,在Windows系统上我们需要链接到winsock的library得到套接字的功能。
这里有一个简单的技巧来做到这一点,并且不需要更改您的项目或makefile
#if PLATFORM == PLATFORM_WINDOWS
#pragma comment( lib, "wsock32.lib" )
#endif
我喜欢这么使用,因为我超级懒,当然你总是可以在你的工程或者makefile中进行链接,如果你喜欢的话。
Initializing the socket layer
大部分类unix平台(包括macosx)不需要任何特定的步骤来初始化这个套接字层,然而Windows如果你跳过这一步,你的套接字则不能正常工作。你必须调用“WSAStartup”函数来初始化你的套接字层在你使用任何套接字前,并使用“WSACleanup”来关闭。
让我们来添加新函数
inline bool InitializeSockets()
{
#if PLATFORM == PLATFORM_WINDOWS
WSADATA WsaData;
return WSAStartup( MAKEWORD(2,2), &WsaData ) == NO_ERROR;
#else
return true;
#endif
}
inline void ShutdownSockets()
{
#if PLATFORM == PLATFORM_WINDOWS
WSACleanup();
#endif
}
现在我们有一个平*立的方式来初始化套接字层。如果平台不要求初始化套接字,那么这个函数就不会做任何事。
Creating a socket
现在是创建UDP套接字,这里是如何做:
int handle = socket( AF_INET, SOCK_DGRAM, IPPROTO_UDP );
if ( handle <= 0 )
{
printf( "failed to create socket\n" );
return false;
}
接下来绑定套接字到一个端口号(比如30000)。每个套接字绑定到一个不同的端口号上,因为一个包到达的端口号决定哪个套接字来发送它。不要使用低于1024的端口号,因为这些是保留给系统使用的。
特别注意的是,如果你不关心你的套接字绑定到哪个端口上,你可以使用“0”作为你的端口,系统会自动给你分配一个空闲的端口号。
sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons( (unsigned short) port );
if ( bind( handle, (const sockaddr*) &address, sizeof(sockaddr_in) ) < 0 )
{
printf( "failed to bind socket\n" );
return false;
}
现在套接字已经准备好发送和接收数据了。
但是为什么会在代码前神秘地调用“htons”?这是一个帮助函数将一个16bit的整型转换为从主机字节序(小或高位优先)转换为网络字节序(高位优先)。这是必须地要求每当你直接设置套接字整数类型的结构成员。
你会看到htons和它的32位整数大小的近亲函数htonl多次使用在本文中, 所以留意,你会知道是怎么回事。
Setting the socket as non-blocking
默认设置套接字设置为阻塞模式。这就意味着如果你使用”recvfrom”不读取套接字,这个函数将不会返回直到有可用的数据包。这并不适合我们的目的。视频游戏是实时程序,模拟在30或60帧每秒,他们不能只是呆在那里,等待一个数据包到达。
这个解决方案能在你创建套接字后,使你的套接字变为非阻塞模式。一旦这么做了,”recvfrom’函数立即返回即使没有可用的数据包,返回值会告诉你稍后再读取数据包。
这里是如何将一个套接字转为非阻塞模式。
#if PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX
int nonBlocking = 1;
if ( fcntl( handle, F_SETFL, O_NONBLOCK, nonBlocking ) == -1 )
{
printf( "failed to set non-blocking socket\n" );
return false;
}
#elif PLATFORM == PLATFORM_WINDOWS
DWORD nonBlocking = 1;
if ( ioctlsocket( handle, FIONBIO, &nonBlocking ) != 0 )
{
printf( "failed to set non-blocking socket\n" );
return false;
}
#endif
当你看到上面这些,windows没有提供”fcntl”函数,所以我们使用”ioctlsocket”函数来代替。
Sendingpackets
UDP是一个无连接模式的传输协议,所以每次发包前你必须指定目的地址。你可以使用一个UDP套接字来发送数据包到任意数量的不同的IP地址。在UDP的另一端并没有一台计算机你正在连接。
这里是如何发送数据包到指定的地址:
int sent_bytes = sendto( handle, (const char*)packet_data, packet_size,
0, (sockaddr*)&address, sizeof(sockaddr_in) );
if ( sent_bytes != packet_size )
{
printf( "failed to send packet: return value = %d\n", sent_bytes );
return false;
}
注意!“sendto”函数的返回值表明本机数据包是否发送成功。但它并没有告诉你这个数据包是否被目的计算机接收。UDP没有任何方式知道这个数据包是否到达了目的地。
在上面的代码中,我们传入了一个参数“sockaddr_in”的结构作为目的地址。我们如何来设置这个结构体呢?
比如我们打算发送数据到207.45.186.98:30000
以如下形式开始我们的地址
unsigned int a = 207;
unsigned int b = 45;
unsigned int c = 186;
unsigned int d = 98;
unsigned short port = 30000;
我们还有一些工作要做,完成sendto的形式要求。
unsigned int destination_address = ( a << 24 ) | ( b << 16 ) | ( c << 8 ) | d;
unsigned short destination_port = port;
sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl( destination_address );
address.sin_port = htons( destination_port );
正如你所看见,我们结合a,b,c,d(范围是0,255)值到一个单独的整型,每个字节的整数都是相应的输入值。我们接下来用整型地址和端口号初始化“sockaddr_in”结构,确保我们的地址与端口号通过使用“htonl”和“htons”函数由主机字节序转为网络字节序。
特例:如果你想向自己发送数据包,并不需要查询你的本机IP,就用回环地址127.0.0.1,数据包就会送到你的本机上。
Receivingpackets
一旦你端口上已经绑定了一个UDP套接字,任何发到你IP和端口号的UDP数据包就会放在队列中。接收数据包只是循环调用 “recvfrom”,直到它失败表明没有更多的包留在队列。
因为UDP是无连接传输模式,数据包可以来自从任意数量的不同的电脑。每次你调用“recvfrom”接收数据包,都会得到发送者的IP地址和端口号,所以你可以知道这个数据包来自哪里。
这里是怎么循环接收所有入站的数据包
while ( true )
{
unsigned char packet_data[256];
unsigned int maximum_packet_size = sizeof( packet_data );
#if PLATFORM == PLATFORM_WINDOWS
typedef int socklen_t;
#endif
sockaddr_in from;
socklen_t fromLength = sizeof( from );
int received_bytes = recvfrom( socket, (char*)packet_data, maximum_packet_size,
0, (sockaddr*)&from, &fromLength );
if ( received_bytes <= 0 )
break;
unsigned int from_address = ntohl( from.sin_addr.s_addr );
unsigned int from_port = ntohs( from.sin_port );
// process received packet
}
如果在队列中的数据包大于你的接收缓冲区就会被悄悄丢掉。所以如果你有256个字节的缓冲区来接收数据包像上面的代码所示,但有人发给你300字节的包,这个300字节的包就会被丢掉。你不可能收到300字节的前256个字节。
因为是你自己编写你自己的游戏网络协议,在实际工作中,这没有任何问题,只是要确保你的接收缓冲足够大,超过你代码中最大的发送数据包。
Destroying a socket
在大多数类似UNIX的平台, 是文件句柄,所以你可以使用标准的“close”函数来关闭套接字,一旦你停止使用它们。然而,Windows平台下有点不同,所以我们用“closesocket”函数来代替。
#if PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX
close( socket );
#elif PLATFORM == PLATFORM_WINDOWS
closesocket( socket );
#endif
Socket class
所以我们已经实现了所有的基本操作:创建一个套接字,绑定到端口,设置为非阻塞模式,发送和接收数据,销毁套接字。
但是你已经注意到大多数这些操作都因为平台不同,而有稍微差别。当你每次完成套接字的某些操作时你需要记住使用#ifdef 并指定特定的平台,这很麻烦。
我们可以通过包装我们的套接字函数到类内来解决这个问题。我们还可以添加一个”Address”类来简单地指定网络地址。这样我们在每次收发数据时,可以避免手动的编码和解码“sockaddr_in”结构。
这里是我们的套接字类:
class Socket
{
public:
Socket();
~Socket();
bool Open( unsigned short port );
void Close();
bool IsOpen() const;
bool Send( const Address & destination, const void * data, int size );
int Receive( Address & sender, void * data, int size );
private:
int handle;
};
这里是地址类:
class Address
{
public:
Address();
Address( unsigned char a, unsigned char b, unsigned char c, unsigned char d, unsigned short port );
Address( unsigned int address, unsigned short port );
unsigned int GetAddress() const;
unsigned char GetA() const;
unsigned char GetB() const;
unsigned char GetC() const;
unsigned char GetD() const;
unsigned short GetPort() const;
bool operator == ( const Address & other ) const;
bool operator != ( const Address & other ) const;
private:
unsigned int address;
unsigned short port;
};
这里是你如何使用这些类来收发数据:
// create socket
const int port = 30000;
Socket socket;
if ( !socket.Open( port ) )
{
printf( "failed to create socket!\n" );
return false;
}
// send a packet
const char data[] = "hello world!";
socket.Send( Address(127,0,0,1,port), data, sizeof( data ) );
// receive packets
while ( true )
{
Address sender;
unsigned char buffer[256];
int bytes_read = socket.Receive( sender, buffer, sizeof( buffer ) );
if ( !bytes_read )
break;
// process packet
}
正如你看到的一样,这比直接使用BSD套接字简单多了。另好的是,这段代码几乎可用在所有的平台上,因为所有平台细节的处理都包含在你的socket和 address 类中。
Conclusion
现在我们有平*立的方式来收发UDP数据包。
UDP是无连接传模式,我想创建一个事例程序来证明这点。所以,我写了个简单的例子,它从文本文件中读取IP地址,然后每秒发送一个数据包到这些地址。每次这个程序收到一个数据包,就会打印出来这个包来自哪里,并打印出包的大小。
你可以很容易的设置它,这样在本地机器上你就有大量的节点发送数据包到对方,传递不同的端口号码到应用程序的多个实例,像这样:
> Node 30000
> Node 30001
> Node 30002
etc...
然后每个节点将尝试发送数据包到对方节点,它像一个迷你点对点的设置
我在MacOSX开发了这个程序,但是你应该能够在任何类unix系统或Windows很容易编译,所以让我知道你是否有任何修改来兼容不同主机。一旦你尝试稍稍修改事例程序,那么将会有更有趣的事发生。在下一章中,我将展示给你怎么基于UDP协议建立一个虚拟连接,加入和超时退出。