原文链接

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

RUDP之二 —— Sending and Receiving Packets的更多相关文章

  1. Monitoring and Tuning the Linux Networking Stack: Receiving Data

    http://blog.packagecloud.io/eng/2016/06/22/monitoring-tuning-linux-networking-stack-receiving-data/ ...

  2. Tinyos学习笔记(二)

    1.TinyOS communication tools java serialApp -comm serial@/dev/ttyUSB0:telosb java net.tinyos.tools.L ...

  3. Socket programming in C on Linux | tutorial

    TCP/IP socket programming This is a quick guide/tutorial to learning socket programming in C languag ...

  4. the Linux Kernel: Traffic Control, Shaping and QoS

    −Table of Contents Journey to the Center of the Linux Kernel: Traffic Control, Shaping and QoS 1 Int ...

  5. Python socket – network programming tutorial

    原文:https://www.binarytides.com/python-socket-programming-tutorial/ --------------------------------- ...

  6. PatentTips - Highly-available OSPF routing protocol

    BACKGROUND OF THE INVENTION FIG. 1A is a simplified block diagram schematically representing a typic ...

  7. python scapy的使用总结

    基本命令 ls() List all available protocols and protocol options lsc() List all available scapy command f ...

  8. WCF学习系列二---【WCF Interview Questions – Part 2 翻译系列】

    http://www.topwcftutorials.net/2012/09/wcf-faqs-part2.html WCF Interview Questions – Part 2 This WCF ...

  9. 树莓派与Arduino Leonardo使用NRF24L01无线模块通信之基于RF24库 (二) 发送自定义数据

    在我的项目里,树莓派主要作为中心节点,用于接收数据,Arduino作为子节点,用于发送数据,考虑到以后会有很多子节点,但又不至于使得代码过于繁琐,因此所有的传输数据添加一个头部编号用于区分不同节点. ...

随机推荐

  1. Java开发面试

    有很多文章说面试相关的问题,有国内也有国外的,但是我相信不少人,特   别是新人看完后还是觉得比较虚比较泛,似乎好像懂了,但是一遇到面试还   是有些手无足措或者重复犯一些错误.本篇文章正是结合实际经 ...

  2. vim 支持C++11 lambda表达式

    http://www.vim.org/scripts/script.php?script_id=3797 Tar contains just the required .vim files, so u ...

  3. [Nhibernate]SchemaExport工具的使用(一)——通过映射文件修改数据表

    目录 写在前面 文档与系列文章 SchemaExport工具 SchemaUpdate工具 一个例子 总结 写在前面 上篇文章介绍了使用代码生成器的nhibernate模版来生成持久化类,映射文件等内 ...

  4. (四)SQL Server分区管理

    一.拆分分区(SPLIT) 在已有分区上添加一个新分区. 如下图所示,将分区03拆分成03和04分区,拆分方式先锁定旧03分区的所有数据,后将旧03分区相关数据迁移到分区04,最后删除旧03上的对应分 ...

  5. Development of large-scale site performance optimization method from LiveJournal background

    A LiveJournal course of development is a project in the 99 years began in the campus, a few people d ...

  6. Shell入门教程:流程控制(6)while 循环

    while循环的语法: while 条件测试 do     命令区域 done 举例: #!/bin/bash declare -i i=1 declare -i sum=0 while ((i< ...

  7. ReactiveCocoa源码拆分解析(三)

    (整个关于ReactiveCocoa的代码工程可以在https://github.com/qianhongqiang/QHQReactive下载) 这一章节主要讨论信号的“冷”与“热” 在RAC的世界 ...

  8. java.lang.UnsatisfiedLinkError: Couldn't load BaiduMapSDK 的解决方法

    遇到找不到so的同学们可以先从以下几个方面来检查问题: 1.so的名字是不是被修改了?我们SDK的so名字是固定的,如果您自行对它进行了重命名操作,那肯定是没法找到so的.2.so放置位置不对.so需 ...

  9. Spring预处理

    当需要在某些Spring项目一启动,就执行某些操作时,需要实现改接口ApplicationListener,重写onApplicationEvent方法,将需要的预处理操作全部写在该方法中 当初始化完 ...

  10. Retroactive priority queues

    http://erikdemaine.org/papers/Retroactive_TALG/paper.pdf 明天写..大概就是通过一些结论发现这个东西其实就是往最后的集合里加入或删除一些可以被快 ...