第 10 章 基于UDP协议的接收和发送

第 10 章 基于UDP协议的接收和发送

UDP协议是User Datagram Protocol的简写,它是无连接的,不可靠的网络协议。本章将介绍如何使用UDP协议进行程序设计,对UDP编程的基本框架进行介绍并给出程序设计的例子。本章中使用比较大的篇幅对UDP协议的程序设计中容易出现的问题进行分析,并给出解决的方法。主要包括以下内容:

  • UDP的编程框架,主要介绍进行UDP协议程序设计时,客户端和服务器端的两种不同的编程流程;

  • 介绍UDP协议程序设计常用的函数,对函数recv()/recvfrom()、send()/sendto()进行介绍;

  • 介绍一个使用UDP协议进行程序设计的实例;

  • 分析使用UDP协议进行程序设计时经常出现的问题,例如数据报文的丢失、数据的乱序、缺乏流量控制、发送数据时的外出接口、接收数据时的数据截断等,并给出一些解决方法。

10.1 UDP编程框架

使用UDP进行程序设计可以分为客户端和服务器端两部分。服务器端主要包含建立套接字、将套接字与地址结构进行绑定、读写数据、关闭套接字几个过程。客户端包括建立套接字、读写数据、关闭套接字几个过程。服务器端和客户端两个流程之间的主要差别在于对地址的绑定(bind())函数,客户端可以不用进行地址和端口的绑定操作。

10.1.1 UDP编程框图

UDP协议的程序设计框架如图10.1所示,客户端和服务器之间的差别在于服务器必须使用bind()函数来绑定侦听的本地UDP端口,而客户端则可以不进行绑定,直接发送到服务器地址的某个端口地址。

{%}

图 10.1 UDP程序设计框架

与TCP程序设计相比较,UDP缺少了connect()、listen()及accept()函数,这是用于UDP协议无连接的特性,不用维护TCP的连接、断开等状态。

1. UDP协议的服务器端流程

UDP协议的服务器端程序设计的流程分为套接字建立、套接字与地址结构进行绑定、收发数据、关闭套接字等过程,分别对应于函数socket()、bind()、sendto()、recvfrom()和close()。

建立套接字过程使用socket()函数,这个过程与TCP协议中的含义相同,不过建立的套接字类型为数据报套接字。地址结构与套接字文件描述符进行绑定的过程中,与TCP协议中的绑定过程不同的是地址结构的类型。当绑定操作成功后,可以调用recvfrom()函数从建立的套接字接收数据或者调用sendto()函数向建立的套接字发送网络数据。当相关的处理过程结束后,需要调用close()函数关闭套接字。

2. UDP协议的客户端流程

UDP协议的服务器端程序设计的流程分为套接字建立、收发数据、关闭套接字等过程,分别对应于函数socket()、sendto()、recvfrom()和close()。

建立套接字过程使用socket()函数,这个过程与TCP协议中的含义相同,不过建立的套接字类型为数据报套接字。建立套接字之后,可以调用函数sendto()向建立的套接字发送数据或者调用recvfrom()函数从建立的套接字收网络数据。当相关的处理过程结束后,需要调用close()函数关闭套接字。

3. UDP协议服务器和客户端之间的交互

UDP协议中服务器和客户端的交互存在于数据的收发过程中。进行网络数据收发的时候,服务器和客户端的数据是对应的:客户端发送数据的动作,对服务器来说是接收数据的动作;客户端接收数据的动作,对服务器来说是发送数据的动作。

UDP协议服务器与客户端之间的交互,与TCP协议的交互相比较,缺少了二者之间的连接。这是由于UDP协议的特点决定的,因为UDP协议不需要流量控制、不保证数据的可靠性收发,所以不需要服务器和客户端之间建立连接的过程。

10.1.2 UDP服务器编程框架

图10.1中对UDP协议中的服务器程序框架进行了说明。服务器流程主要分为下述6个部分,即建立套接字、设置套接字地址参数、进行端口绑定、接收数据、发送数据、关闭套接字等。

1. 建立套接字文件描述符,使用函数socket(),生成套接字文件描述符,例如:

int s = socket(AF_INET, SOCK_DGRAM, 0);

建立一个AF_INET族的数据报套接字,UDP协议的套接字使用SOCK_DGRAM选项。

2. 设置服务器地址和侦听端口,初始化要绑定的网络地址结构,例如:

struct sockaddr addr_serv;
addr_serv.sin_family = AF_INET;                    /*地址类型为AF_INET*/
addr_serv.sin_addr.s_addr = htonl(INADDR_ANY);     /*任意本地地址*/
addr_serv.sin_port = htons(PORT_SERV);             /*服务器端口*/

地址结构的类型为AF_INET;IP地址为任意的本地地址;服务器的端口为用户定义的端口地址;注意成员sin_addr.s_addr和sin_port均为网络字节序。

3. 绑定侦听端口,使用bind()函数,将套接字文件描述符和一个地址类型变量进行绑定,例如:

bind(s, (struct sockaddr*)&addr_serv, sizeof(addr_serv));  /*绑定地址*/

4. 接收客户端的数据,使用recvfrom()函数接收客户端的网络数据。

5. 向客户端发送数据,使用sendto()函数向服务器主机发送数据。

6. 关闭套接字,使用close()函数释放资源。

10.1.3 UDP客户端编程框架

在图10.1中,同样对UDP协议的客户端流程进行了描述,按照图中所示,UDP协议的客户端流程分为套接字建立、设置目的地址和端口、向服务器发送数据、从服务器接收数据、关闭套接字5个部分。与服务器端的框架相比,少了bind()部分,客户端程序的端口和本地的地址可以由系统在使用时指定。在使用sendto()和recvfrom()的时候,网络协议栈会临时指定本地的端口和地址,流程如下:

1. 建立套接字文件描述符,socket();

2. 设置服务器地址和端口,struct sockaddr;

3. 向服务器发送数据,sendto();

4. 接收服务器的数据,recvfrom();

5. 关闭套接字,close()。

10.2 UDP协议程序设计的常用函数

UDP协议常用的函数有recv()/recvfrom()、send()/sendto()、socket()、bind()等。当然这些函数同样可以用于TCP协议的程序设计。

10.2.1 建立套接字socket()和绑定套接字bind()

UDP协议建立套接字的方式同TCP方式一样,使用socket()函数,只不过协议的类型使用SOCK_DGRAM,而不是SOCK_STREAM。例如,下面是建立一个UDP套接字文件描述符的代码。

int s;
s =  socket(AF_INET, SOCK_DGRAM, 0);

UDP协议使用bind()函数的方法与TCP没有什么差别,将一个套接字描述符与一个地址结构绑定在一起。例如,下面的代码将一个本地的地址和套接字文件描述符绑定在了一起。

struct sockaddr_in local;                           /*本地的地址信息*/
int from_len = sizeof(from);                        /*地址结构的长度*/
local. sin _family = AF_INET;                       /*协议族*/
local. sin _port = htons(8888);                     /*本地端口*/
local. sin _addr.s_addr = htonl(INADDR_ANY);        /*任意本地地址*/
s = socket(AF_INET, SOCK_DGRAM, 0);    /*初始化一个IPv4族的数据报套接字*/
if (s == -1) {                         /*检查是否正常初始化socket*/
    perror("socket");
    exit(EXIT_FAILURE);
}
bind(s, (struct sockaddr*)&local,sizeof(local));    /*套接字绑定*/

绑定函数bind()使用的时机,即什么时候需要绑定需要介绍一下。函数bind()的作用是将一个套接字文件描述符与一个本地地址绑定在一起,即把发送数据的端口地址和IP地址进行了指定。例如在发送数据的时候,如果不进行绑定,则会临时选择一个随机的端口。

10.2.2 接收数据recvfrom()/recv()

当客户端成功建立了一个套接字文件描述符并构建了合适的struct sockaddr结构或者服务器端成功地将套接字文件描述符和地址结构绑定后,可以使用recv()或者recvfrom()来接收到达此套接字文件描述符上的数据,或者在这个套接字文件描述符上等待数据的到来。

1. recv()函数和recvfrom()函数介绍

recv()函数和recvfrom()函数的原型如下:

#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int s, void*buf, size_t len, int flags);
ssize_t recvfrom(int s, void*buf, size_t len, int flags,
             struct sockaddr*from, socklen_t*fromlen);

第1个参数s表示正在监听端口的套接口文件描述符,它由函数socket()生成。

第2个参数buf表示接收数据缓冲区,接收到的数据将放在这个指针所指向的内存空间中。

第3个参数len表示接收数据缓冲区的大小,系统根据这个值来确保接收缓冲区的安全,防止溢出。

第4个参数from是指向本地的数据结构sockaddr_in的指针,接收数据时发送方的地址信息放在这个结构中。

第5个参数fromlen表示第4个参数所指内容的长度,可以使用sizeof(struct sockaddr_in)来获得。

recv()函数和recvfrom()函数的返回值在出错的时候返回-1;在成功的时候,函数将返回接收到的数据长度,数据的长度可以为0,因此如果函数返回值为0,并不表示发生了错误,仅能表示此时系统中接收不到数据。发生错误的时候,错误值保存在errno中,error的值和含义如表10.1所示。

表 10.1 recv的errno值及含义

含义

EAGAIN

接收超时,或者套接字描述符设置为非阻塞,而此时没有数据

EAGAIN/EWOULDBLOCK

此socket使用了非阻塞模式,当前情况下没有可接收的连接

EBADF

描述符非法

ECONNABORTED

连接取消

EINTR

信号在合法连接到来之前打断了accept的系统调用

EINVAL

socket没有侦听连接或者地址长度不合法

EMFILE

每个进程允许打开的文件描述符数量最大值已经到达

ENFILE

达到系统允许打开文件的总数量

ENOTSOCK

文件描述符是一个文件,不是socket

EOPNOTSUPP

引用的socket不是流类型SOCK_STREAM

EFAULT

参数addr不可写

ENOBUFS/ENOMEM

内存不足

EPROTO

协议错误

EPERM

防火墙不允许连接

注意:函数recvfrom()中的参数from和fromlen均为指针,不要直接将地址结构类型和地址类型的长度传入函数中,需要进行取地址的运算。

2. 使用recvfrom()函数的例子

下面是一个简单的例子,通过这个例子读者可以了解如何使用recvfrom()函数,以及什么时候使用recvfrom()函数。

下面的代码,先建立一个数据报套接字文件描述符s,在地址结构local设置完毕后,将套接字s与地址结构local绑定在一起。

#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
int main(int argc, char*argv[])
{
    int s;                                      /*套接字文件描述符*/
    struct sockaddr_in from;                    /*发送方的地址信息*/
    struct sockaddr_in local;                   /*本地的地址信息*/
    int from_len = sizeof(from);                /*地址结构的长度*/
    int n;                                      /*接收到的数据长度*/
    char buf[128];                              /*接收数据缓冲区*/
    s = socket(AF_INET, SOCK_DGRAM, 0); /*初始化一个IPv4族的数据报套接字*/
    if (s == -1) {                              /*检查是否正常初始化socket*/
        perror("socket");
        exit(EXIT_FAILURE);
    }

    local. sin _family = AF_INET;                     /*协议族*/
    local. sin _port = htons(8888);                   /*本地端口*/
    local. sin _addr.s_addr = htonl(INADDR_ANY);      /*任意本地地址*/
    bind(s, (struct sockaddr*)&local,sizeof(local));  /*套接字绑定*/

套接字与地址绑定成功后,服务器可以直接通过这个套接字接收数据,recvfrom()函数从套接字s中每次可以接收128个字节的数据并保存到缓冲区buff中。函数recvfrom()所接收数据的来源可以从变量from中获得,包含发送数据的主机IP地址、端口等信息,变量from_len是发送数据主机地址结构的类型长度。

        n = recvfrom(s, buff, 128, 0, (struct sockaddr*)&from, &from_len);
    if(n == -1){                                        /*接收数据出错*/
        perror("recvfrom");
        exit(EXIT_FAILURE);
    }
    /*处理数据*/
    ...
}

上面的例子在使用recvfrom()函数的时候,没有绑定发送方的地址。所以在接收数据的时候要判断发送方的地址,只有合适的发送方才能进行相应的处理,因为不同的发送方发送的数据都可以到达接收方的套接字文件描述符,这是由于UDP协议没有按照连接进行区分造成的,如图10.2所示。

{%}

图 10.2 多个发送方均可以到达接收方

3. 应用层recv()函数和内核函数的关系

应用层的recvfrom()和内核层的recvfrom()的关系参见图10.3。应用层的recvfrom()函数对应内核层的sys_recvfrom()系统调用函数。

{%}

图 10.3 应用层recvfrom()和内核层sys_recvfrom()的关系图

系统调用函数sys_recvfrom主要查找文件描述符对应的内核socket结构;建立一个消息结构;将用户空间的地址缓冲区指针和数据缓冲区指针打包到消息结构中;在套接字文件描述符中对应的数据链中查找对应的数据;将数据复制到消息中;销毁数据链中的数据;将数据复制到应用层空间;减少文件描述符的引用计数。

sys_recvfrom()调用函数sockfd_lookup_light()查找到文件描述符对应的内核socket结构后,会申请一块内存用于保存连接成功的客户端状态。socket结构的一些参数,例如类型type、操作方式ops等会继承服务器原来的值,如果原来服务器的类型为AF_INET,则其操作模式仍然是af_inet.c文件中的各个函数。然后会查找文件描述符表,获得一个新结构对应的文件描述符。

在内核空间使用了一个消息结构msghdr用来存放所有的数据结构,其原型如下:

struct msghdr {
    void    *    msg_name;                  /*Socket名称*/
    int     msg_namelen;                    /*Socket名称的长度*/
    struct iovec*   msg_iov;                /*向量,存放数据*/
    __kernel_size_t msg_iovlen;             /*向量数量*/
    void    *   msg_control;                /*协议幻数*/
    __kernel_size_t msg_controllen;         /*msg_control的数量*/
    unsigned    msg_flags;                  /*消息选项*/
};

结构的成员msg_name和msg_namelen中存放发送方的地址相关的信息。一个向量放在msg_iov中,存放接收到的数据。向量成员iov_base指向用户传入的接收数据缓冲区地址,iov_len为用户传入的接收缓冲区长度。其示意图参见图10.4。

{%}

图 10.4 消息结构msghdr存放recvfrom的各个参数

对于AF_INET族,recvfrom对应于udp_recvmsg()函数,其实现在文件af_inet.c中。分为如下步骤:

1. 接收数据报数据。在接收的时候根据设置的超时时间来确定是否要一直等待至数据到来。例如当flag为MSG_DONTWAIT时,仅仅查看一下,如果没有数据就退出;否则就一直等至超时时间的到来。在接收数据的时候,根据是否设置了MSG_PEEK标志,决定是否将数据复制后销毁数据,或者仅仅将数据复制,而不销毁其中的数据。

2. 计算复制出的数据长度,当接收到的数据长度比用户缓冲区的长度大时,设置MSG_TRUNC标志,方便下一次的复制。

3. 将数据复制到用户缓冲区空间。

4. 复制发送方的地址和协议族。

5. 根据消息结构的标志设置,接收其他的信息,例如TTL、TOS、选项等。

6. 销毁数据报缓冲区的对应变量。

10.2.3 发送数据sendto()/send()

当客户端成功地建立了一个套接字文件描述符,并构建了合适的struct sockaddr结构或者服务器端成功地将套接字文件描述符和地址结构绑定后,可以使用send()或者sendto()函数来发送数据到某个主机上。

1. send()函数和sendto()函数介绍

send()函数和sendto()函数的原型如下:

#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int s, const void*buf, size_t len, int flags);
ssize_t  sendto(int  s,  const  void*buf, size_t len, int flags, const
      struct sockaddr*to, socklen_t tolen);

第1个参数s是正在监听端口的套接口文件描述符,通过函数socket获得。

第2个参数buf是发送数据缓冲区,发送的数据放在此指针指向的内存空间中。

第3个参数len是发送数据缓冲区的大小。

第4个参数to指向目的主机数据结构sockaddr_in的指针,接收数据的主机地址信息放在这个结构中。

第5个参数tolen表示第4个参数所指内容的长度,可以使用sizeof(struct sockaddr_in)来获得。

send()函数和sendto()函数的返回值在调用出错的时候返回-1;在调用成功的时候,返回发送成功的数据长度,数据的长度可以为0,因此函数返回值为0的时候是合法的。发生错误时errno的值如表10.2所示。

表 10.2 send()函数的errno值及含义

含义

EAGAIN

接收超时,或者套接字描述符设置为非阻塞,而此时没有数据

EAGAIN/EWOULDBLOCK

此socket使用了非阻塞模式,当前情况下没有可接收的连接

EBADF

描述符非法

ECONNABORTED

连接取消

EINTR

信号在合法连接到来之前打断了accept的系统调用

EINVAL

socket没有侦听连接或者地址长度不合法

EMFILE

每个进程允许打开的文件描述符数量最大值已经到达

ENFILE

达到系统允许打开文件的总数量

ENOTSOCK

文件描述符是一个文件,不是socket

EOPNOTSUPP

引用的socket不是流类型SOCK_STREAM

EFAULT

参数addr不可写

ENOBUFS/ENOMEM

内存不足

EPROTO

协议错误

EPERM

防火墙不允许连接

2. 使用函数sendto()的例子

下面是一个使用sendto()函数发送数据的简单例子。在这个例子中,先调用socket()函数产生一个数据报类型的套接字文件描述符;然后设置发送数据的目的主机的IP地址和端口,将这些数值赋给地址结构;当地址结构设置完毕后,调用sendto()函数将需要发送的数据通过sendto()函数发送出去。

#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
int main(int argc, char*argv[])
{
    int s;                                     /*套接字文件描述符*/
    struct sockaddr_in to;                     /*接收方的地址信息*/
    int n;                                     /*发送到的数据长度*/
    char buf[128];                             /*发送数据缓冲区*/
    s = socket(AF_INET, SOCK_DGRAM, 0); /*初始化一个IPv4族的数据报套接字*/
    if (s == -1) {                             /*检查是否正常初始化socket*/
        perror("socket");
        exit(EXIT_FAILURE);
    }

    to.sin_family = AF_INET;                   /*协议族*/
    to.sin_port = htons(8888);                 /*本地端口*/
    to.sin_addr.s_addr = inet_addr("192.168.1.1");
                                       /*将数据发送到主机192.169.1.1上*/

    n = sendto(s, buff, 128, 0, (struct sockaddr*)&to, sizeof (to));
                                       /*将数据buff发送到主机to上*/
    if(n == -1){                       /*发送数据出错*/
        perror("sendto");
        exit(EXIT_FAILURE);
    }
    /*处理过程*/
    …
}

sendto()函数发送的过程比较简单,如图10.5所示。在本例的发送过程中,由于没有设置本地的IP地址和本地端口,而这些参数是网络协议栈发送数据时的必需条件,所以在UDP层网络协议栈会选择合适的端口。发送的网络数据经过IP层的时候,客户端会选出合适的本地IP地址进行填充,并且将客户端的目的IP地址填充到IP报文中。发送的数据到达数据链路层的时候,会根据硬件的情况进行发送。

{%}

图 10.5 发送数据sendto()示意图

3. 应用层sendto()函数和内核函数的关系

应用层的sendto()和内核层的sendto()的关系参见图10.6。应用层的sendto()函数对应内核层的sys_sendto()系统调用函数。系统调用函数sys_sendto()查找文件描述符对应的内核socket结构、建立一个消息结构、将用户空间的地址缓冲区指针和数据缓冲区指针打包到消息结构中、在套接字文件描述符中对应的数据链中查找对应的数据、将数据复制到消息中、更新路由器信息、将数据复制到IP层、减少文件描述符的引用计数。

{%}

图 10.6 应用层sendto和内核层sys_sendto的关系图

sys_sendto()调用函数sockfd_lookup_light()查找到文件描述符对应的内核socket结构后,会申请一块内存用于保存连接成功的客户端的状态。socket结构的一些参数,例如类型type、操作方式ops等会继承服务器原来的值,如果原来服务器的类型为AF_INET,那么它的操作模式仍然是在文件af_inet.c中定义的各个函数。然后会查找文件描述符表,获得一个新结构对应的文件描述符。在内核空间使用了一个消息结构msghdr用来存放所有的数据结构,其原型如下:

struct msghdr {
    void    *    msg_name;                  /*Socket名称*/
    int     msg_namelen;                    /*Socket名称的长度*/
    struct iovec*   msg_iov;                /*向量,存放数据*/
    __kernel_size_t msg_iovlen;             /*向量数量*/
    void    *   msg_control;                /*协议幻数*/
    __kernel_size_t    msg_controllen;      /*msg_control的数量*/
    unsigned    msg_flags;                  /*消息选项*/
};

在结构的成员msg_name和msg_namelen中存放发送方地址相关的信息。建立一个向量放在msg_iov中,用于存放发送的数据。向量成员iov_base指向用户传入的发送数据缓冲区地址,iov_len为用户传入的发送缓冲区长度。消息结构msghdr存放recvfrom的各个参数如图10.7所示。

{%}

图 10.7 消息结构msghdr存放recvfrom()的各个参数

对于AF_INET族,sendto()对应于udp_sendmsg()函数,其实现在文件af_inet.c中。分为如下步骤:

1. 发送数据报数据。在发送数据的时候,查看是否设置了pending,如果设置了此项,则仅仅进行检查是否可以发送数据,然后退出。如果选项中设置了OOB,则退出,不能进行此项的发送。

2. 确定接收方的地址和协议族。

3. 将数据复制到用户缓冲区空间。

4. 根据消息结构的标志设置,发送其他的信息,如TTL、TOS、选项等。

5. 查看是否为广播,如果是,则更新广播地址。

6. 更新路由。

7. 将数据放入IP层。

8. 销毁数据报缓冲区的对应变量。

10.3 UDP接收和发送数据的例子

本节将介绍一个简单的UDP服务器和客户端的例子,说明如何使用UDP函数进行程序设计。例子的程序框架如图10.8所示,客户端向服务器发送字符串UDP TEST,服务器接收到数据后将接收到的字符串发送回客户端。

{%}

图 10.8 简单的UDP客户端服务器

10.3.1 UDP服务器端

UDP的服务器端与TCP服务器端十分相似,不过流程要简单得多。服务器的代码如下,其步骤为:

1. 建立一个套接字文件描述符s。

2. 填充地址结构addr_serv,协议为AF_INET,地址为任意地址,端口为PORT_SERV(8888)。

3. 将套接字文件描述符s绑定到地址addr_serv。

4. 调用udpserv_echo()函数处理客户端数据。

01  #include <sys/types.h>
02  #include <sys/socket.h>                        /*包含socket()/bind()*/
03  #include <netinet/in.h>                        /*包含struct sockaddr_in*/
04  #include <string.h>                            /*包含memset()*/
05  #define PORT_SERV 8888                         /*服务器端口*/
06  int main(int argc, char*argv[])
07  {
08      int s;                                     /*套接字文件描述符*/
09      struct sockaddr_in addr_serv,addr_clie;    /*地址结构*/
10
11      s = socket(AF_INET, SOCK_DGRAM, 0);        /*建立数据报套接字*/
12
13      memset(&addr_serv, 0, sizeof(addr_serv));  /*清空地址结构*/
14      addr_serv.sin_family = AF_INET;            /*地址类型为AF_INET*/
15      addr_serv.sin_addr.s_addr = htonl(INADDR_ANY); /*任意本地地址*/
16      addr_serv.sin_port = htons(PORT_SERV);     /*服务器端口*/
17
18      bind(s, (struct sockaddr*)&addr_serv, sizeof(addr_serv));
19                                                     /*绑定地址*/
20      udpserv_echo(s, (struct sockaddr*)&addr_clie); /*回显处理程序*/
21
22      return 0;
23  }

10.3.2 UDP服务器端数据处理

函数udpserv_echo()的代码如下,其中的处理过程很简单,服务器循环等待客户端的数据,当服务器接收到客户端的数据后,将接收到的数据发回给客户端。

01  #define BUFF_LEN 256                   /*缓冲区大小*/
02  void static udpserv_echo(int s, struct sockaddr*client)
03  {
04      int n;                             /*接收数据长度*/
05      char buff[BUFF_LEN];               /*接收发送缓冲区*/
06      socklen_t len;                     /*地址长度*/
07      while(1)                           /*循环等待*/
08      {
09          len = sizeof(*client);
10          n = recvfrom(s, buff, BUFF_LEN, 0, client, &len);
11      /*接收数据放到buff中,并获得客户端地址*/
12          sendto(s, buff, n, 0, client, len);
                                           /*将接收到的n个字节发送回客户端*/
13      }
14  }

10.3.3 UDP客户端

UDP客户端向服务器端发送数据UDP TEST,然后接收服务器端的回复信息,并将服务器端的数据打印出来。客户端的代码如下,其步骤为:

1. 建立一个套接字文件描述符s。

2. 填充地址结构addr_serv,协议为AF_INET,地址为任意地址,端口为PORT_SERV(8888)。

3. 将套接字文件描述符s绑定到地址addr_serv。

4. 调用udpclie_echo()函数和服务器通信。

01  #include <sys/types.h>
02  #include <stdio.h>
03  #include <unistd.h>
04  #include <sys/socket.h>                        /*包含socket()/bind()*/
05  #include <netinet/in.h>                        /*包含struct sockaddr_in*/
06  #include <string.h>                            /*包含memset()*/
07  #define PORT_SERV 8888                         /*服务器端口*/
08  int main(int argc, char*argv[])
09  {
10      int s;                                     /*套接字文件描述符*/
11      struct sockaddr_in addr_serv;              /*地址结构*/
12
13      s = socket(AF_INET, SOCK_DGRAM, 0);        /*建立数据报套接字*/
14
15      memset(&addr_serv, 0, sizeof(addr_serv));  /*清空地址结构*/
16      addr_serv.sin_family = AF_INET;            /*地址类型为AF_INET*/
17      addr_serv.sin_addr.s_addr = htonl(INADDR_ANY); /*任意本地地址*/
18      addr_serv.sin_port = htons(PORT_SERV);         /*服务器端口*/
19
20      udpclie_echo(s, (struct sockaddr*)&addr_serv); /*客户端回显程序*/
21
22      close(s);
23      return 0;
24  }

10.3.4 UDP客户端数据处理

udpclie_echo()函数的代码如下,其中处理过程同样简单,向服务器端发送字符串UDP TEST,接收服务器的响应,并将接收到的服务器数据打印出来。

01  #define BUFF_LEN 256                           /*缓冲区大小*/
02  static void udpclie_echo(int s, struct sockaddr*to)
03  {
04      char buff[BUFF_LEN] = "UDP TEST";          /*发送给服务器的测试数据
05  */
06      struct sockaddr_in from;                   /*服务器地址*/
07      socklen_t len = sizeof(*to);               /*地址长度*/
08      sendto(s, buff, BUFF_LEN, 0, to, len);     /*发送给服务器*/
09      recvfrom(s, buff, BUFF_LEN, 0, (struct sockaddr*)&from, &len);
10                                                 /*从服务器接收数据*/
11      printf("recved:%s\n",buff);                /*打印数据*/
12  }

10.3.5 测试UDP程序

将服务器端的代码存到udp_server01.c文件中,将客户端的代码存放到udp_client01.c文件中。按照如下方式进行编译:

$gcc -o udp_server01 udp_server01.c
$gcc -o udp_client01 udp_client01.c

先运行服务器程序,这时UDP服务器会在8888端口等待数据到来。

$ ./udp_server01

再运行客户端的程序,客户端向服务器端发送字符串UDP TEST,并接收服务器的信息反馈。

$ ./udp_client01

则客户端的输出为:

recved:UDP TEST

10.4 UDP协议程序设计中的几个问题

相对于TCP协议的程序设计,UDP协议的程序虽然程序设计的环节要少一些,但是由于UDP协议缺少流量控制等机制,容易出现一些难以解决的问题。UDP的报文丢失、报文乱序、connect()函数、流量控制、外出网络接口的选择等是比较容易出现的问题,本节将对此进行介绍并给出初步的解决建议方法。

10.4.1 UDP报文丢失数据

利用UDP协议进行数据收发的时候,在局域网内一般情况下数据的接收方均能接收到发送方的数据,除非连接双方的主机发生故障,否则不会发生接收不到数据的情况。

1. UDP报文的正常发送过程

而在Internet上,由于要经过多个路由器,正常情况下一个数据报文从主机C经过路由器A、路由器B、路由器C到达主机S,数据报文的路径如图10.9所示。主机C使用函数sendto()发送数据,主机S使用recvfrom()函数接收数据,主机S在没有数据到来的时候,会一直阻塞等待。

{%}

图 10.9 UDP数据在Internet网发送的正常情况

2. UDP报文的丢失

路由器要对转发的数据进行存储、处理、合法性判定、转发等操作,容易出现错误,所以很可能在路由器转发的过程中出现数据丢失的现象,如图10.10所示。当UDP的数据报文丢失的时候,函数recvfrom()会一直阻塞,直到数据到来。

{%}

图 10.10 路由器丢弃发送过程中的UDP数据报文

在10.3节的UDP服务器客户端的例子中,如果客户端发送的数据丢失,服务器会一直等待,直到客户端合法数据到来;如果服务器的响应在中间被路由器丢弃,则客户端会一直阻塞,直到服务器数据的到来。在程序正常运行的过程中是不允许出现这种情况的,所以可以设置超时时间来判断是否有数据到来。对于数据丢失的原因,并不能通过一种简单的方法获得,例如,不能区分服务器发给客户端的响应数据是在发送的路径中被路由器丢弃,还是服务器没有发送此响应数据。

3. UDP报文丢失的对策

UDP协议中的数据报文丢失是先天性的,因为UDP是无连接的、不能保证发送数据的正确到达。图10.11为TCP连接中发送数据报文的过程,主机C发送的数据经过路由器,到达主机S后,主机S要发送一个接收到此数据报文的响应,主机C要对主机S的响应进行记录,直到之前发送的数据报文1已经被主机S接收到。如果数据报文在经过路由器的时候,被路由器丢弃,则主机C和主机S会对超时的数据进行重发。

{%}

图 10.11 TCP的超时重发机制

10.4.2 UDP数据发送中的乱序

UDP协议数据收发过程中,会出现数据的乱序现象。所谓乱序是发送数据的顺序和接收数据的顺序不一致,例如发送数据的顺序为数据包A、数据包B、数据包C,而接收数据包的顺序变成了数据包B、数据包A、数据包C。

1. UDP数据顺序收发的过程

如图10.12所示,主机C向主机S发送数据包0、数据包1、数据包2、数据包3,各个数据包途中经过路由器A、路由器B、路由器C,先后到达主机S,在主机S端的循序仍然为数据包0、数据包1、数据包2、数据包3,即发送数据时的顺序和接收数据时的顺序是一致的。

{%}

图 10.12 数据包正常顺序接收的过程

2. UDP数据的乱序

UDP的数据包在网络上传输的时候,有可能造成数据的顺序更改,接收方的数据顺序和发送方的数据顺序发生了颠倒。这主要是由于路由的不同和路由的存储转发的顺序不同造成的。

路由器的存储转发可能造成数据顺序的更改,如图10.13所示。主机C发送的数据在经过路由器A和路由器C的时候,顺序均没有发生更改。而在经过主机B的时候,数据的顺序由数据0123变为了0312,这样主机C的数据0123顺序经过路由器到达主机S的时候变为了数据0312。

{%}

图 10.13 路由器存储转发造成的顺序更改

UDP协议的数据经过路由器时的路径造成了发送数据的混乱,如图10.14所示。从主机C发送的数据0123,其中数据0和3经过路由器B、路由器C到达主机S,数据1和数据2经过路由器A和路由器C到达主机S,所以数据由发送时的顺序0123变成了顺序1032。

{%}

图 10.14 路由器路径不同造成的顺序更改

3. UDP乱序的对策

对于乱序的解决方法可以采用发送端在数据段中加入数据报序号的方法,这样接收端对接收到数据的头端进行简单地处理就可以重新获得原始顺序的数据,如图10.15所示。

{%}

图 10.15 UDP协议数据乱序的解决示意图

10.4.3 UDP协议中的connect()函数

UDP协议的套接字描述符在进行了数据收发之后,才能确定套接字描述符中所表示的发送方或者接收方的地址,否则仅能确定本地的地址。例如客户端的套接字描述符在发送数据之前,只要确定建立正确就可以了,在发送的时候才确定发送目的方的地址;服务器bind()函数也仅仅绑定了本地进行接收的地址和端口。

connect()函数在TCP协议中会发生三次握手,建立一个持续的连接,一般不用于UDP。在UDP协议中使用connect()函数的作用仅仅表示确定了另一方的地址,并没有其他的含义。

connect()函数在UDP协议中使用后会产生如下的副作用:

  • 使用connect()函数绑定套接字后,发送操作不能再使用sendto()函数,要使用write()函数直接操作套接字文件描述符,不再指定目的地址和端口号。

  • 使用connect()函数绑定套接字后,接收操作不能再使用recvfrom()函数,要使用read()类的函数,函数不会返回发送方的地址和端口号。

  • 在使用多次connect()函数的时候,会改变原来套接字绑定的目的地址和端口号,用新绑定的地址和端口号代替,原有的绑定状态会失效。可以使用这种特点来断开原来的连接。

下面是一个使用connect()函数的例子,在发送数据之前,将套接字文件描述符与目的地址使用connect()函数进行了绑定,之后使用write()函数发送数据并使用read()函数接收数据。

01  static void udpclie_echo(int s, struct sockaddr*to)
02  {
03      char buff[BUFF_LEN] = "UDP TEST";          /*向服务器端发送的数据*/
04      connect(s, to, sizeof(*to));               /*连接*/
05
06      n = write(s, buff, BUFF_LEN);              /*发送数据*/
07
08      read(s, buff, n);                          /*接收数据*/
09  }

10.4.4 UDP缺乏流量控制

UDP协议没有TCP协议所具有的滑动窗口概念,接收数据的时候直接将数据放到缓冲区中。如果用户没有及时地从缓冲区中将数据复制出来,后面到来的数据会接着向缓冲区中放入。当缓冲区满的时候,后面到来的数据会覆盖之前的数据而造成数据的丢失。

1. UDP缺乏流量控制概念

如图10.16所示为UDP的接收缓冲区示意图,共有8个缓冲区,构成一个环状数据缓冲区。起点为0。

图 10.16 UDP 协议的接收缓冲区

当接收到数据后,会将数据顺序放入之前数据的后面,并逐步递增缓冲区的序号,如图10.17所示。

图 10.17 UDP协议的接收缓冲区接收过程

当数据没有接收或者接收数据比发送数据的速率要慢,之前接收的数据被覆盖,造成数据的丢失,如图10.18所示。

图 10.18 UDP协议接收缓冲区的溢出

2. 缓冲区溢出对策

解决UDP接收缓冲区溢出的现象需要根据实际情况确定,一般可以用增大接收数据缓冲区和接收方接收单独处理的方法来解决局部的UDP数据接收缓冲区溢出问题。

例如,在局部时间内发送方会爆发性地发送大量的数据,在后面的时间,发送的数据会较小,由于在局部时间内接收方不能及时处理接收到的数据,会造成数据的丢失,如果增大缓冲区,则可以改善此问题。如果接收方的接收能力在绝对能力上要小于发送方,则接收方由于在处理能力或者容量方面的限制,造成数据肯定要丢失。

例如,对10.3节中的代码进行如下的修改,实现上述的解决方法。客户端的代码如下,先将发送计数的值打包进发送缓冲区,然后复制要发送的数据,再进行数据发送。每次发送的时候,计数器增加1。

01  #define PORT_SERV 8888                         /*服务器端口*/
02  #define NUM_DATA 100                           /*接收缓冲区数量*/
03  #define LENGTH 1024                            /*单个接收缓冲区大小*/
04  static char buff_send[LENGTH];                 /*接收缓冲区*/
05  static void udpclie_echo(int s, struct sockaddr*to)
06  {
07      char buff_init[BUFF_LEN] = "UDP TEST";     /*向服务器端发送的数据*/
08      struct sockaddr_in from;                   /*发送数据的主机地址*/
09      int len = sizeof(*to);                     /*地址长度*/
10      int i = 0;                                 /*计数*/
11      for(i = 0; i< NUM_DATA; i++)               /*循环发送*/
12      {
13          *((int*)&buff_send[0]) = htonl(i);     /*将数据标记打包*/
14          memcpy(&buff_send[4],buff_init, sizeof(buff_init));
15                                                 /*数据复制到发送缓冲区*/
16          sendto(s, &buff_send[0], NUM_DATA, 0, to, len);   /*发送数据*/
17      }
18  }

服务器端的代码如下,接收到发送方的数据后,判断接收到数据的计数器的值,将不同计数器的值放入缓冲区不同的位置,在使用的时候可以判断一下计数器是否正确,即是否有数据到来,再进行使用。

01  #define PORT_SERV 8888                             /*服务器端口*/
02  #define NUM_DATA 100                               /*接收缓冲区数量*/
03  #define LENGTH 1024                                /*单个接收缓冲区大小
04  */
05  static char buff[NUM_DATA][LENGTH];                /*接收缓冲区*/
06  static udpserv_echo(int s, struct sockaddr*client)
07  {
08      int n;                                         /*接收数量*/
09      char tmp_buff[LENGTH];                         /*临时缓冲区*/
10      int len;                                       /*地址长度*/
11      while(1)                                       /*接收过程*/
12      {
13          len = sizeof(*client);                     /*地址长度*/
14          n = recvfrom(s, tmp_buff, LENGTH, 0, client, &len);
                                              /*接收数据放到临时缓冲区中*/
15          /*根据接收到数据的头部标志,选择合适的缓冲区位置复制数据*/
16          memcpy(&buff[ntohl(*((int*)&buff[i][0]))][0], tmp_buff+4, n-4);
17      }
18  }

10.4.5 UDP协议中的外出网络接口

在网络程序设计的时候,有时需要设置一些特定的条件。例如,一个主机有两个网卡,由于不同的网卡连接不同的子网,用户发送的数据从其中的一个网卡发出,将数据发送到特定的子网上。使用函数connect()可以将套接字文件描述符与一个网络地址结构进行绑定,在地址结构中所设置的值是发送接收数据时套接字采用的IP地址和端口。下面的代码是一个例子:

01  #include <sys/types.h>
02  #include <sys/socket.h>    /*socket()/bind()*/
03  #include <netinet/in.h>    /*struct sockaddr_in*/
04  #include <string.h>        /*memset()*/
05  #include <stdio.h>
06  #include <arpa/inet.h>
07  #include <unistd.h>
08  #define PORT_SERV 8888
09  int main(int argc, char*argv[])
10  {
11      int s;                                         /*套接字文件描述符*/
12      struct sockaddr_in addr_serv;                  /*服务器地址*/
13      struct sockaddr_in local;                      /*本地地址*/
14      socklen_t len = sizeof(local);                 /*地址长度*/
15
16      s = socket(AF_INET, SOCK_DGRAM, 0);            /*生成数据报套接字*/
17
18      /*填充服务器地址*/
19      memset(&addr_serv, 0, sizeof(addr_serv));      /*清零*/
20      addr_serv.sin_family = AF_INET;                /*AF_INET协议族*/
21      addr_serv.sin_addr.s_addr =inet_addr("127.0.0.1");
                                                   /*地址为127.0.0.1*/
22      addr_serv.sin_port = htons(PORT_SERV);     /*服务器端口*/
23
24      connect(s, (struct sockaddr*)&addr_serv, sizeof(addr_serv));
                                                   /*连接服务器*/
25      getsockname(s, (struct sockaddr*)&local, &len);
                                           /*获得套接字文件描述符的地址*/
26      printf("UDP local addr:%s\n",inet_ntoa(local.sin_addr));
                                                   /*打印获得的地址*/
27
28      close(s);
29      return 0;
30  }

编译并运行后其结果如下,系统将程序中的套接字描述符与本地的回环接口进行了 绑定。

UDP local addr:127.0.0.1

10.4.6 UDP协议中的数据报文截断

当使用UDP协议接收数据的时候,如果应用程序传入的接收缓冲区的大小小于到来数据的大小时,接收缓冲区会保存最大可能接收到的数据,其他的数据将会丢失,并且有MSG_TRUNC的标志。

例如对函数udpclie_echo()做如下修改,发送一个字符串后在一个循环中接收服务器端的响应,会发现只能接收一个U,程序阻塞到recvfrom()函数中。这是因为服务器发送的字符串到达客户端后,客户端的第一次接收动作没有正确地接收到全部的数据,其余的数据已经丢失了。

01  static void udpclie_echo(int s, struct sockaddr*to)
02  {
03      char buff[BUFF_LEN] = "UDP TEST";          /*要发送的数据*/
04      struct sockaddr_in from;                   /*发送方的地址结构*/
05      int len = sizeof(*to);                     /*发送的地址结构长度*/
01      sendto(s, buff, BUFF_LEN, 0, to, len);     /*发送数据*/
02      int i = 0;                                 /*接收数据的次数*/
03      for(i = 0; i< 16; i++)
04      {
05          memset(buff, 0, BUFF_LEN);             /*清空缓冲区*/
06          int err = recvfrom(s, buff, 1, 0, (struct sockaddr*)&from, &len);
                                                   /*接收数据*/
07          printf("%dst:%c,err:%d\n",i,buff[0],err);  /*打印数据*/
08      }
09      printf("recved:%s\n",buff);                /*打印信息*/
10  }

所以服务器和客户端的程序要进行配合,接收的缓冲区要比发送的数据大一些,防止数据丢失的现象发生。

10.5 小结

本章介绍了如何使用UDP协议进行套接字编程,也介绍了UDP编程的程序框架,客户端和服务器端有不同的流程。另外还介绍了recvfrom()函数和sendto()函数。用一个简单的例子介绍了UDP编程的基本情况。

在使用UDP进行程序设计的时候会碰到很多情况,如接收端的数据淹没、数据的乱序、数据报文的接收端截断、发送网络接口的绑定等。相对于TCP协议,由于不需要进行流量控制、数据应答、状态维护等特点,UDP的一个显著优点是速度要比TCP快得多,所以很多服务器端使用UDP进行通信。而且,有一些协议是仅仅基于UDP协议的,如DNS、广播等。

目录

  • 前言
  • 第 1 篇 Linux网络开发基础
  • 第 1 章 Linux操作系统概述
  • 第 2 章 Linux编程环境
  • 第 3 章 文件系统简介
  • 第 4 章 程序、进程和线程
  • 第 2 篇 Linux用户层网络编程
  • 第 5 章 TCP/IP协议族简介
  • 第 6 章 应用层网络服务程序简介
  • 第 7 章 TCP网络编程基础
  • 第 8 章 服务器和客户端信息的获取
  • 第 9 章 数据的IO和复用
  • 第 10 章 基于UDP协议的接收和发送
  • 第 11 章 高级套接字
  • 第 12 章 套接字选项
  • 第 13 章 原始套接字
  • 第 14 章 服务器模型选择
  • 第 15 章 IPv6简介
  • 第 3 篇 Linux内核网络编程
  • 第 16 章 Linux内核中网络部分结构以及分布
  • 第 17 章 netfilter框架内报文处理
  • 第 4 篇 综合案例
  • 第 18 章 一个简单Web服务器的例子SHTTPD
  • 第 19 章 一个简单网络协议栈的例子SIP
  • 第 20 章 一个简单防火墙的例子SIPFW