Sending and Receiving Packets
How to send and receive packets over UDP with BSD sockets
Posted by Glenn Fiedler on Friday,October 3,2008
Introduction
Hi, I’m Glenn Fiedler and welcome to Networking for Game Programmers.
在上一篇文章中,我们讨论了在计算机之间发送数据的可选项,并决定对时间关键型数据使用UDP而不是TCP。
在本文中,我将向您展示如何发送和接收UDP包。
BSD sockets
对于大多数现代平台,您都有一些基于BSD sockets的基本socket层。
BSD套接字使用“socket”、“bind”、“sendto”和“recvFrom”等简单函数进行操作。当然,如果愿意的话,您可以直接使用这些函数,但是很难保持代码与平台独立,因为每个平台略有不同。
因此,尽管我将首先向您展示BSD套接字示例代码来演示基本的套接字用法,但我们不会长时间直接使用BSD套接字。一旦我们介绍了所有基本的套接字功能,我们将把所有内容抽象成一组类,使您能够轻松地编写平台独立的套接字代码。
Platform specifics
首先,让我们设置一个define,这样我们可以检测当前平台是什么,并处理从一个平台到另一个平台的套接字之间的细微差异:
// 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
现在,我们让套接字包含适当的头文件。由于头文件是特定于平台的,因此我们将使用平台定义来基于平台包含不同的文件集:
#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库以获取套接字功能。
以下是一个简单的技巧,可以在不更改项目或生成文件的情况下完成此操作:
#if PLATFORM == PLATFORM_WINDOWS
#pragma comment( lib, "wsock32.lib" )
#endif
我喜欢这个把戏,因为我太懒了。如果愿意,您可以随时从项目或makefile链接。
Initializing the socket layer
大多数类Unix平台(包括MacOSX)不需要任何特定的步骤来初始化套接字层,但是Windows要求您进行一些操作以使套接字代码正常工作。
在调用任何套接字函数之前,必须调用“WSAStartup”来初始化套接字层,并在完成后调用“WSACleanup”来关闭。
让我们添加两个新函数:
bool InitializeSockets(){
#if PLATFORM == PLATFORM_WINDOWS
WSADATA WsaData;
return WSAStartup( MAKEWORD(2,2),
&WsaData )
== NO_ERROR;
#else
return true;
#endif
}
void ShutdownSockets(){
#if PLATFORM == PLATFORM_WINDOWS
WSACleanup();
#endif
}
现在我们有了一种独立于平台的方法来初始化套接字层。
###Creating a socket
是时候来创建socket了:
int handle = socket( AF_INET,
SOCK_DGRAM,
IPPROTO_UDP );
if ( handle <= 0 ){
printf( "failed to create socket\n" );
return false;
}
接下来,我们将UDP套接字绑定到一个端口号(例如30000)。每个套接字必须绑定到一个唯一的端口,因为当一个包到达时,端口号决定要传递到哪个套接字。不要使用低于1024的端口,因为它们是为系统保留的。还要尽量避免使用50000以上的端口,因为它们在动态分配端口时使用。
特殊情况:如果您不关心您的套接字绑定到什么端口,只需将“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;
}
现在,socket已经做好发送接收数据的准备了。
但是在上面的代码中,这个对“htons”的神秘调用是什么?这只是一个助手函数,它将16位整数值从主机字节顺序(小端或大端)转换为网络字节顺序(大端)。在socket结构中直接设置整数成员时,这是必需的。
在本文中,您将看到“htons”(host to network short)及其32位整数大小的堂兄“htonl”(host to network long),使用了多次,因此请注意你知道发生了什么。
Setting the socket as non-blocking
默认情况下,socket被设置为“阻塞模式”。
这意味着,如果你试图使用“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\n" );
return false;
}
#elif PLATFORM == PLATFORM_WINDOWS
DWORD nonBlocking = 1;
if ( ioctlsocket( handle,
FIONBIO,
&nonBlocking ) != 0 )
{
printf( "failed to set non-blocking\n" );
return false;
}
#endif
Windows 没有提供“fcntl”方法,因此我们用“ioctlsocket”代替。
Sending packets
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\n" );
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 address = ( a << 24 ) |
( b << 16 ) |
( c << 8 ) |
d;
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl( address );
addr.sin_port = htons( port );
如你所见,我们首先将范围[0,255]内的a、b、c、d值组合为一个无符号整数,整数的每个字节现在对应于输入值。然后,我们用整型地址和端口初始化一个“sockaddr-in”结构,使用“htonl”和“htons”将整型地址和端口值从主机字节顺序转换为网络字节顺序。
特殊情况:如果您想给自己发送一个包,不需要查询您自己机器的IP地址,只需传入环回地址127.0.0.1,包就会被发送到本地机器。
Receiving packets
一旦您将一个UDP套接字绑定到一个端口,发送到套接字IP地址和端口的任何UDP数据包都将放置在一个队列中。要接收数据包,只需循环并调用“recvfrom”,直到它失败为止,EWOULDBLOCK指示没有更多的数据包要接收。
由于UDP是无连接的,数据包可能来自任意数量的不同计算机。每次你收到一个包时,“recvfrom”都会告诉你发送者的IP地址和端口,这样你就知道包是从哪里来的。
以下是如何循环和接收所有传入数据包:
while ( true )
{
unsigned char packet_data[256];
unsigned int max_packet_size =
sizeof( packet_data );
#if PLATFORM == PLATFORM_WINDOWS
typedef int socklen_t;
#endif
sockaddr_in from;
socklen_t fromLength = sizeof( from );
int bytes = recvfrom( socket,
(char*)packet_data,
max_packet_size,
0,
(sockaddr*)&from,
&fromLength );
if ( 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
windows 万岁。
Socket class
所以我们已经介绍了所有的基本操作:创建一个套接字,将其绑定到一个端口,将其设置为非阻塞,发送和接收数据包,以及销毁套接字。
但您会注意到,这些操作中的大多数都与平台稍有依赖性,每次执行套接字操作时,都必须记住ifdef并执行平台特定操作,这非常烦人。
我们将把所有的套接字功能包装成一个“socket”类来解决这个问题。在这里,我们将添加一个“地址”类,以便更容易地指定Internet地址。这就避免了每次发送或接收数据包时都必须手动编码或解码“sockaddr-in”结构。
让我们来添加socket类:
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;
};
和 address 类:
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;
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简单的多。
作为额外的好处,代码在所有平台上都是相同的,因为平台特定的所有内容都是在套接字和地址类中处理的。
Conclusion
现在,你有了一种独立于平台的方式来发送和接收数据包。Enjoy ?