网络游戏程序员须知 收包与发包

本文为作者原创翻译并且加入了一些自己的思路和观点,转载请注明。

作者:[email protected]
首发链接:http://blog.csdn.net/rellikt/archive/2010/08/23/5833233.aspx
原文链接:http://gafferongames.com/networking-for-game-programmers/sending-and-receiving-packets/
简介
 大家好,今天我们就来说说网络游戏程序员须知的第二篇:收包与发包。
 上一篇中我们比较了UDP和TCP两种协议,最后的结论是我们必须使用更方便订制的UDP协议来做游戏的网络传输协议以便于我们的游戏能有更好的实时性,不至于因为丢包等问题造成不必要的麻烦。
 现在就让我写点实际的代码来具体说明吧。  
BSD socket
 现代的平台系统中,大多数会有基于BSD端口的端口协议支持。
 BSD协议端口一般是会有一些类似于“socket”, “bind”, “sendto”,“recvfrom”的API, 当然你可以直接使用这些API来做你游戏的相关模块,不过如果你真的要这样写的话,那么你的代码的可移植性就会很差,因为不同平台往往在API上还是有些小不同的。
 接下来尽管我会用BSD的接口API写一些示例代码来展示我们需要的功能,但是我们不会一直使用那些API。我的意思是当我们把基础的东西都弄明白以后,我们需要适当的做抽象,然后在抽象层面上继续开发。使用这个技巧我们的代码就会平台无关了。  by rellikt
平台相关部分 
 我们首先要定义一些宏,使用这些宏,我们就可以识别当前的平台,这样我们的代码才能针对不同的平台使用不同的API。

    // 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
 端口API是操作系统的系统库,不需要加额外的库链接,只需要包括头文件就可以了。但是在Windows平台上我们需要额外的指明链接的库。下面是示例代码:    #if PLATFORM == PLATFORM_WINDOWS
    #pragma comment( lib, "wsock32.lib" )
    #endif
 我必须喜欢这样加库到工程的链接里面,我比较懒。当然你也可以比较正规的在工程文件里面添。这个随便你了。 by rellikt
初始化端口层
 多数操作系统(包括苹果的macosx)是不需要对端口层做初始化的,但是Windows是例外,你如果想用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
    } 现在我们有了一个好的开头,我们的端口层已经与平台无关了,并且不需要初始化端口,这些小事是我们开始的时候必须要认真完成的细节。

创建一个端口
 现在就让我们来创建一个UDP端口吧, 看代码:

    int handle = socket( AF_INET, SOCK_DGRAM, IPPROTO_UDP );

    if ( handle <= 0 )
    {
        printf( "failed to create socket/n" );
        return false;
    } 接下来,我们需要给端口绑定一个端口号(比如:40000)。每个端口都必须有一个端口号,这样当你收到包的时候,机器才能知道改发给哪个端口。不用用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”,这个函数是干嘛的呢?这个函数其实是用来调整低端比特“little-endian”和高端比特“big-endian”的不同的操作系统可能会使用不同的比特模式。如果你想直接给端口赋值16bit的整形变量,那么你必须使用这个函数。这个函数还有一个32bit的兄弟函数,用来倒32bit的整数。这篇文章中我们会有不少涉及,留神了。 by rellikt

将端口设为无阻塞模式
 默认的端口设置是阻塞模式。这意味着如果你想用“recvfrom”读一个包,那么在这个包可以被完整读取以前,recvfrom都不会返回。我们的游戏是实时模拟的,一般会限帧在30fps或者60fps。这种阻塞模式不是我们想要的模式。

 我们这里在创建完端口以后需要把端口调到无阻塞模式。这种模式下,调用recvfrom会立即返回,不同的是如果包还不能被读取,recvfrom会给出一个返回值告诉我们读取不成功。下次还要再读。 by rellikt

 下面是设置端口为无阻塞模式的代码:

    #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”函数,所以我们要用“loctlsocket”函数来做代替。

发包
 UDP不是基于连接的传输协议,因此你每个包都得包含目标的地址和端口号。你可以在同一个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;
    } 注意!这里的返回值是表示这个包是否成功发出的意思。它并不能告诉你这个包是否被成功收到。事实上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;

 

    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的范围内,然后将他们和目标整数的不同比特位关联起来,然后用htonl或者htons来保证比特位的高端和低端问题。还是挺简单的吧。

 需要特别主要的:本机localhost的IP是127.0.0.1这个是保留IP地址。如果你想做单机测试,尽管使用这个IP地址就可以了。

收包
 当你给你的UDP端口绑定了一个端口号以后,你就可以收包了。事实上那些收到的包会先进入端口的队列,然后你可以通过一个循环来进行不停的“recvfrom”这个队列直到recvfrom返回值表示队列里已经没有包可读。

 我们知道UDP其实是没有连接这个概念的,所以说即使是同一个UDP端口队列,里面也可能会包含不同机器发来的UDP包,这些包里都会有发送者的IP和端口号,你可以在读这些包的时候得到这些信息。 by rellikt

 下面还是看代码:

    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
    } 队列里那些比你的缓冲区更大的包会被默认丢弃。所以谨记你必须开一个足够大的缓冲区,不然那些超过你缓冲区大小的包,你就不

用想了。我们这里是用UDP实现我们自己的网络传输协议,所以不需要有太多限制,记住开足够大的缓冲区就可以了。 

销毁端口
 大多数的类Unix系统上,端口和文件句柄一样,你只需要用close像关文件句柄一样消耗端口一样就可以了。切记要消耗,不然导致的句柄溢出是很麻烦的。Windows系统上我们要做的稍微要麻烦点。大致我们可以如下来完成这个工作:

    #if PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX
    close( socket );
    #elif PLATFORM == PLATFORM_WINDOWS
    closesocket( socket );
    #endif端口类
 我们这里已经介绍了所有的基本操作:开端口,绑定端口号,设置无阻塞模式,收包,发包,销毁端口。

 但是你是否觉得我们用到的那些平台相关的代码有点让人觉得不好处理呢?靠一堆#define什么的来编码显得很不好用,另外关于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;
        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
    }有了这个抽象类我们的生活会变得简单不少,另外我们这里用到的代码就已经和平台无关了。我们可以在底层处理那些麻烦的API。结论
 我们至此已经有了一个平台无关的可以用来收发UDP包的基本架构了。 UDP是连接无关的,我这里写了一个示例代码来说明这个问题。这段代码从一个txt文件中读取一些IP地址,然后每隔一秒钟会对这些IP地址发一个包。每次本机收到包的时候则会打出收到包的来源和包的大小。你可以修改一下参数,然后启动多个示例来做实验,如果你这样做的话,其实你已经在实现一种简单的p2p的网络架构了。 by rellikt

 这个示例代码是在mac上做的,不过在其他平台上编译通过应该也不会有问题。

 这篇的内容就到此为止了,如果你没有任何问题我想我们可以进行下一章的内容了。下一章我会告诉你如何做一个可以实际应用的UDP协议。就这样吧。

 

本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/rellikt/archive/2010/08/23/5833233.aspx

你可能感兴趣的:(网络游戏程序员须知 收包与发包)