RUDP之二 —— Sending and Receiving Packets

时间:2022-10-13 23:40:22

原文链接

原文: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协议建立一个虚拟连接,加入和超时退出。