目录
一、socket简介
二、socket编程接口函数介绍
2.1 socket()函数(创建socket)
2.2 bind()函数(绑定地址和端口)
2.3 listen()函数(设置socket为监听模式)
2.4 accept()函数(接受连接)
2.5 connect()函数 (连接服务器)
2.6 读数据函数
2.7 写数据函数
2.8 字节序转换函数
2.9 IP地址格式转换函数
三、面向数据报(UDP)的socket编程
3.1 UDP服务端编程和测试
3.2 UDP客户端编程和测试
3.3 select()函数
一、socket简介
现在的网络编程接口通常是socket,很多文献中文翻译做“套接字”。用socket能够实现网络上的不同主机之间或同一主机的不同对象之间的数据通信。所以,现在socket已经是一类通用通信接口的集合。
套接字(socket)是Linux下的一种进程间通信机制(socket IPC),在前面的内容中已经给大家提到过,使用socket IPC可以使得在不同主机上的应用程序之间进行通信(网络通信),当然也可以是同一台主机上的不同应用程序。socket IPC通常使用客户端<—>服务器这种模式完成通信,多个客户端可以同时连接到服务器中,与服务器之间完成数据交互。
内核向应用层提供了socket接口,对于应用程序开发人员来说,我们只需要调用socket接口开发自己的应用程序即可。socket是应用层与TCP/IP协议通信的中间软件抽象层,它是一组接口。在设计模式中,socket其实就是一个门面模式,它把复杂的TCP/IP协议隐藏在socket接口后面,对用户来说,一组简单的接口就是全部,让 socket去组织数据,以符合指定的协议。所以,我们无需深入的去理解tcp/udp等各种复杂的TCP/IP 协议,socket已经为我们封装好了,我们只需要遵循socket的规定去编程,写出的程序自然遵循tcp/udp标准的。
当前网络中的主流程序设计都是使用 socket 进行编程的,因为它简单易用,它还是一个标准(BSD socket),能在不同平台很方便移植,比如你的一个应用程序是基于socket接口编写的,那么它可以移植到任何实现BSD socket标准的平台,比如Windows,它也实现了一套基于socket的套接字接口,又比如在国产操作系统中,如RT-Thread,它也实现了BSD socket标准的socket 接口。
大的类型可以分为网络socket和本地socket两种:
- 本地Socket在Linux上包括Unix Domain Socket和Netlink两种。Unix Domain Socket主要用于进程间通信,NetLink用于用户空间和内核空间通讯,本文暂不做讨论;
- 网络Socket支持很多种不同的协议。
本文所述为网络编程基础内容,没有深入、详细地介绍socket编程,只是网络编程入门。网络编程是一门非常难、非常深奥的技能,市面上有很多关于Linux/UNIX网络编程类书籍,这些书籍专门介绍了网络编程相关知识内容,而且书本非常厚,可见内容之多、难点之多。对于从事网络编程开发相关工作,就要深入学习、研究相关知识了。
注:本文主要讲述基于第四版本的TCP/IP协议族中的TCP和UDP协议的网络编程。此处在后面没有特殊指名时,所有的讨论仅限于IPv4网络的协议族和地址表示。
二、socket编程接口函数介绍
Socket接口提供了socket(2)、bind(2)、listen(2)、accept(2)、connect(2)以及sendto(2)/recvfrom(2)这样的函数接口。在符合要求的情况下,也可以使用read/write系统调用对socket进行数据读写。使用socket接口需要包含两个头文件<sys/types.h>和<sys/socket.h>。
注:“socket(2)”这样的表示形式是Unix文档中通行的表示方式,socket是函数名字,()表示这是一个函数,括号中的2表示这个函数的手册位于手册页2中,可以使用命令:
man 2 socket来进行查看。
对于我们提到的Socket系列函数接口,在Linux上基本的手册都在手册页2中,POSIX兼容的解释在手册页3p中,可以通过man 3p socket这样的命令进行查看。对于一些特有的高级操作和解释,可能会在手册页7中,比如man 7 socket,可以看到一些Linux的Socket高级选项。
根据函数原型,仔细阅读系统自带手册是一个好习惯。
2.1 socket()函数(创建socket)
在进行Socket通信之前,一般调用socket(2)函数来创建一个Socket通信端点。socket(2)函数原型如下:
int socket(int domain, int type, int protocol);
socket()函数类似于open()函数,它用于创建一个网络通信端点(打开一个网络通信),如果成功则返回一个网络文件描述符,通常把这个文件描述符称为socket描述符(socket descriptor),这个socket描述符跟文件描述符一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。参数列表中:
- domain代表这个Socket所使用的地址类型(用于通信的协议族),对于TCP/IP协议来说,通常选择AF_INET就可以了,对于IPv4协议使用AF_INET,也可以使用PF_INET。实际上这两个值是相等的,但是实际上通常大部分人更习惯使用AF_INET。当然如果你的IP协议的版本支持IPv6,那么可以选择AF_INET6。
- type代表了这个Socket的类型,我们讨论范围是有面向流的(TCP)和面向数据报的(UDP)Socket。分别取值SOCK_STREAM和SOCK_DGRAM。
- protocol是协议类型,对于我们的应用场景,都取0即可,表示为给定的通信域和套接字类型选择默认协议。
调用socket()与调用open()函数很类似,调用成功情况下,均会返回用于文件I/O的文件描述符,只不过对于socket()来说,其返回的文件描述符一般称为socket描述符。当不再需要该文件描述符时,可调用close()函数来关闭套接字,释放相应的资源。如果socket()函数调用失败,则会返回-1,并且会设置errno变量以指示错误类型。
创建TCP Socket:
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
创建UDP Socket:
sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
2.2 bind()函数(绑定地址和端口)
创建了Socket后,可以调用bind(2)函数来将这个Socket绑定到特定的地址和端口上来进行通信。函数原型如下:
int bind(int socket, const struct sockaddr *addr, socklen_t addrlen);
bind()函数用于将一个IP地址或端口号与一个套接字进行绑定(将套接字与地址进行关联)。一般来讲,会将一个服务器的套接字绑定到一个众所周知的地址——即一个固定的与服务器进行通信的客户端应用程序提前就知道的地址 (包括 IP 地址和端口号)。因为对于客户端来说,它与服务器进行通信,首先需要知道服务器的IP地址以及对应的端口号,所以通常服务器的IP地址以及端口号都是众所周知的。 参数列表中:
- socket应该是一个指向Socket的有效文件描述符。
- address参数就是一个指向struct sockaddr结构的指针,根据不同的协议,可以有不同的具体结构,对于IP地址,就是struct sockaddr_in。但是在调用函数的时候需要强制转换一下这个指针避免警告。
- addrlen,因为前面的地址可能有各种不同的地址结构,所以,此处应该指明使用的地址数据结构的长度。编程时直接取sizeof(struct sockaddr_in)即可。
对于参数address,一般我们在使用的时候都会使用struct sockaddr_in结构体,sockaddr_in和sockaddr是并列的结构(占用的空间是一样的),指向sockaddr_in的结构体的指针也可以指向sockadd的结构体,并代替它,而且sockaddr_in结构对用户将更加友好,在使用的时候进行类型转换就可以了。
当bind(2)调用成功时返回0,失败时返回-1,这时需要检查errno值(本文不就不一一列举了)。
注意:对于服务器程序,一般需要显式bind(2)到特定端口,这样客户程序才知道连到哪个端口访问服务。但是对于客户端程序,一般的来说可以不用显式bind(2),协议栈会在发起通信是将Socket自动绑定到一个随机的可用端口上进行通信即可,但是显式bind(2)也是可以的。
2.3 listen()函数(设置socket为监听模式)
基于TCP协议的服务器,需调用listen(2)函数将其Socket设置成被动模式,等待客户机的连接。listen()函数只能在服务器进程中使用,让服务器进程进入监听状态,等待客户端的连接请求,listen()函数在一般在bind()函数之后调用,在accept()函数之前调用,该函数原型如下:
int listen(int socket, int backlog);
参数中的socket与前面的函数都相同,backlog是指等待连接的队列长度,但是实际上的队列可能会大于这个数字,通常都取5。参数backlog用来描述sockfd的等待连接队列能够达到的最大值。在服务器进程正处理客户端连接请求的时候,可能还存在其它的客户端请求建立连接,因为TCP连接是一个过程,由于同时尝试连接的用户过多,使得服务器进程无法快速地完成所有的连接请求。因此内核会在自己的进程空间里维护一个队列,这些连接请求就会被放入一个队列中,服务器进程会按照先来后到的顺序去处理这些连接请求,这样的一个队列内核不可能让其任意大,所以必须有一个大小的上限,这个backlog参数告诉内核使用这个数值作为队列的上限。而当一个客户端的连接请求到达并且该队列为满时,客户端可能会收到一个表示连接失败的错误,本次请求会被丢弃不作处理。
调用成功返回0,失败返回-1,此时需要检测处理errno值。
注:无法在一个已经连接的套接字(即已经成功执行connect()的套接字或由accept()调用返回的套接字)上执行listen()。
2.4 accept()函数(接受连接)
服务器调用listen()函数之后,就会进入到监听状态,等待客户端的连接请求,TCP服务器使用accept()函数获取客户端的连接请求并建立连接。函数原型如下:
int accept(int socket, struct sockaddr *addr, socklen_t *addrlen);
参数列表中:socket和前面的函数都一样,参数addr也是一样的结构,但是此处是一个传出参数(是用来返回值的),在成功返回的时候,如果这个指针非空,这里将存储请求连接的客户端的地址和端口。参数addrlen应设置为addr所指向的对象的字节长度,如果我们对客户端的IP地址与端口号这些信息不感兴趣, 可以把arrd和addrlen均置为空指针NULL。
accept(2)调用成功返回一个有效的文件描述符,此文件描述符指向成功与客户端建立连接可以进行数据交换的Socket。服务器程序使用这个文件描述符来与客户端进行后续的交互。调用失败则返回-1,此时需要检测处理errno值。
accept()函数通常只用于服务器应用程序中,如果调用accept()函数时,并没有客户端请求连接(等待连接队列中也没有等待连接的请求),此时accept()会进入阻塞状态,直到有客户端连接请求到达为止。当有客户端连接请求到达时,accept()函数与远程客户端之间建立连接,accept()函数返回一个新的套接字。这个套接字与socket()函数返回的套接字并不同,socket()函数返回的是服务器的套接字(以服务器为例),而accept()函数返回的套接字连接到调用connect()的客户端,服务器通过该套接字与客户端进行数据交互,比如向客户端发送数据、或从客户端接收数据。所以,理解accept()函数的关键点在于它会创建一个新的套接字,其实这个新的套接字就是与执行 connect()(客户端调用 connect()向服务器发起连接请求)的客户端之间建立了连接,这个套接字代表了服务 器与客户端的一个连接。如果 accept()函数执行出错,将会返回-1,并会设置 errno 以指示错误原因。
注意:accept(2)会根据文件描述符的O_NONBLOCK标识设置阻塞模式和非阻塞模式。若是非阻塞模式的,当返回是-1时必须检查errno值是否EAGAIN或者EWOULDBLOCK(没有连接请求时直接返回)。另外accept(2)会被信号中断,这是正常的,在其返回-1时应该检查errno是否为EINTR,如果是被信号中断的,程序一般需要重新启动accept(2)调用。
2.5 connect()函数 (连接服务器)
对于客户机,使用TCP协议时,在通讯前必须调用connect(2)连接到需要通信的服务器的特定通信端点后才能正确进行通信。对于使用UDP协议的客户机,这个步骤是可选项。如果使用了connect(2),在此之后可以不需要指定数据报的目的地址而直接发送,否则每次发送数据均需要指定数据报的目的地址和端口。函数原型如下:
int connect(int socket, const struct sockaddr *addr, socklen_t addrlen);
connect(2)的所有参数以及含义均和bind(2)完全相同,参数addr指定了待连接的服务器的IP地址以及端口号等信息,参数addrlen指定了addr指向的struct sockaddr对象的字节大小。函数执行成功返回0,失败返回-1,此时需要检测处理errno值。
该函数用于客户端应用程序中,客户端调用connect()函数将套接字sockfd与远程服务器进行连接,客户端通过connect()函数请求与服务器建立连接,对于TCP连接来说,调用该函数将发生TCP连接的握手过程,并最终建立一个TCP连接,而对于UDP协议来说,调用这个函数只是在sockfd中记录服务器IP地址与端口号,而不发送任何数据。 函数调用成功则返回 0,失败返回-1,并设置errno以指示错误原因。
2.6 读数据函数
以下函数均可读取Socket数据:read(2)、recv(2)、recvfrom(2)和recvmsg(2)。函数原型分别如下:
ssize_t read(int fd, void *buf, size_t count);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
其中read(2)函数一般用于流式Socket简单读写数据,也就是对应TCP协议。和普通文件的read(2)操作并无不同。当然也可以用于进行过connect(2)操作的UDP Socket文件描述符。
recv(2)函数与read(2)基本相同,但是多一个参数flags,这是一个专门用于读Socket数据的函数,支持很多Socket的标识,flags可以组合,见表1。
recvfrom(2)函数相对于recv(2)增加两个参数,用来返回接收到的数据的源地址,这两个参数的形式和含义都与accept(2)中的后两个参数相同。如果这两个指针被置为NULL,则recvfrom(2)的表现和recv(2)相同。
recvmsg(2)函数则是使用一个struct msghdr的结构来简化了参数,本文不深入了解。
名称 | 含义 | 备注 |
---|---|---|
MSG_CMSG_CLOEXEC | 将接收数据的文件描述符设置标识close-on-exec | 只用于recvmsg(2),且从Linux 2.6.23才开始支持 |
MSG_DONTWAIT | 以非阻塞方式读数据,如果无数据可读则返回-1并设置errno为EAGAIN或者EWOULDBLOCK | 相当于将Socket设置为非阻塞模式,从Linux 2.2开始支持 |
MSG_ERRQUEUE | 如果Socket队列中有错误,接收这个错误,协议相关 | 从Linux 2.2开始支持 |
MSG_OOB | 处理带外数据 | |
MSG_PEEK | 读取队列头部数据但不清除 | 这会导致下一次读操作读到相同的数据 |
MSG_TRUNC | 即使缓冲区长度不够,也返回真实的数据包长度 | 从Linux 2.2开始支持,其中Raw Socket(AF_PACKET)从Linux2.4.27/2.6.8开始支持此特性,Netlink从Linux 2.6.22开始支持,Unix数据报从Linux 3.4开始支持 |
MSG_WAITALL | 阻塞到所有的请求都被满足,通常是填满请求的缓冲区长度才返回 | 从Linux 2.2开始支持;如果被信号中断、发生错误或者连接断开,依然可能未填满缓冲区 |
2.7 写数据函数
相对应的write(2)、send(2)、sendto(2)和sendmsg(2)都可以发送数据到Socket。功能和原型都类似于读数据函数,函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
其中参数含义基本与读数据函数相同,不同的是,sendto(2)的最后一个地址长度参数是值,而读数据函数中recvfrom(2)的最后一个参数是指针。另外支持的flags是不同的,见表2。
名称 | 含义 | 备注 |
---|---|---|
MSG_CONFIRM | 告诉链路层准发过程发生,会收到对端成功的回应 | 从Linux 2.3.15开始支持目前只在IPv4和IPv6实现。 |
MSG_DONTROUTE | 不要经过网管,只发送到直连主机 | 一般用于诊断路由问题,并且只对可以路由的协议起作用 |
MSG_DONTWAIT | 和发数据类似,非阻塞模式 | 从Linux 2.2开始支持 |
MSG_EOR | 终止一个记录 | 只在像SOCK_SEQPACKET这样支持此概念的协议有用,从Linux 2.2开始支持 |
MSG_MORE | 尽可能多的发送数据,对TCP就是累积足够多的数据后再发送,UDP是生成尽可能大的数据报 | 从Linux 2.4.4开始支持,Linux2.6 支持UDP |
MSG_NOSIGNAL | 对面向流的Socket(有连接)在连接断开时不发送SIGPIPE信号 | 依然会设置errno为EPIPE |
MSG_OOB | 发送带外数据 | 只对支持此概念的协议有效 |
2.8 字节序转换函数
在网络应用中,字节序是一个必须考虑的因素,因为不同机器类型可能采用不同标准的字节序,所以均须按照网络标准转化。网络传输的标准叫做网络字节序,实际上是大端序。而我们常用的X86或者ARM往往都是小端序。
在网络编程中不应该假设自己程序运行的主机的字节序,应当使用htonl/htons/ntohs/ntohl之类的函数来在网络字节序和主机字节序之间进行转换。其中h代表host,就是本地主机的表示形式;n代表network,表示网络上传输的字节序,s和l代表类型short和long。
ARM的字节序实际上是可配置的,但是一般都配置为小端。
手工进行字节序的转换往往是不方便的,对于可移植的程序来说更是如此。总是需要知道自己的本地主机字节序也是很麻烦的。所以,系统提供了四个固定的函数,用来在本地字节序和网络字节序之间转换。这四个函数包含在头文件<arpa/inet.h>中,他们的主要作用就是为了避免大小端的问题。分别是:
uint32_t htonl(uint32_t hostlong); //32位整数从主机字节序转换为网络字节序
uint16_t htons(uint16_t hostshort); //16位整数从主机字节序转换为网络字节序
uint32_t ntohl(uint32_t netlong): //32位整数从网络字节序转换为主机字节序
uint16_t ntohs(uint16_t netshort); //16位整数从网络字节序转换为主机字节序
2.9 IP地址格式转换函数
在实际网络编程过程中,往往需要在IP地址的点分十进制表示和二进制表示之间相互转化,也需要进行主机名和地址的转换,系统提供了一系列函数,一般需要包含一下头文件<netinet/in.h>和<arpa/inet.h>。
我们更容易阅读的是点分十进制的IP地址,比如192.168.1.136,这是 一种字符串的形式,但是计算机所需要理解的是二进制形式的IP地址,所以就需要在点分十进制字符串和二进制地址之间进行转换。点分十进制字符串和二进制地址之间的转换函数主要有:inet_aton、inet_addr、inet_ntoa、inet_ntop、 inet_pton这五个。
in_addr_t inet_addr(const char *cp)
这个函数将一个点分十进制的IP地址字符串转换成in_addr_t类型,该类型实际上是一个32位无符号整数,事实上就是前文提到的struct in_addr结构中的s_addr域的数据类型。注意这个二进制表示的IP地址规定是网络字节序。
char *inet_ntoa(struct in_addr in)
此函数可以将结构struct in_addr中的二进制IP地址转换为一个点分十进制表示的字符串,返回这个字符串的首指针。使用起来很方便。但是要注意,它返回的缓冲区是静态分配的,在并发或者异步使用时要小心,可能缓冲区随时可能被其它调用改写。
三、面向数据报(UDP)的socket编程
UDP(User Datagram Protocol,用户数据报协议)是一个面向数据报的传输层协议。UDP的传输是不可靠的,简单的说就是发了不管,发送者不会知道目标地址的数据通路是否发生拥塞,也不知道数据是否到达,是否完整以及是否还是原来的次序。它同TCP一样有用来标识本地应用的端口号。所以应用UDP的应用,都能够容忍一定数量的错误和丢包。
3.1 UDP服务端编程和测试
面向数据报的UDP服务器基本流程如下图所示:创建Socket后调用bind(2)绑定到特定接口就可以直接用这个套接字进行收发数据了。服务器需要使用recvfrom(2)这样的接口来接收数据并获取数据源地址和端口,然后使用sendto(2将数据根据记录的数据源地址和端口回发,即完成一次服务。这个UDP服务器除了检测到错误异常退出外,始终在这个循环中运行。
面向数据报的服务器使用UDP协议,不像TCP服务器那样复杂,每个客户端有单独的连接,所以为了并发需要使用子进程,或者线程和I/O多路复用。UDP协议没有连接状态,只需要记住消息的来源,直接在服务器Socket上读取并回发消息即可。
这里就不能简单像处理普通文件一样读写数据了,需要在接收数据的时候使用recvfrom(2)函数,这个函数会把数据报的源地址和端口结构,以及该数据结构的长度在后面两个参数返回。我们记录这个地址。并使用sendto(2)函数,将数据报回发到来源地址和端口即可。
UDP的服务器程序结构大大简化了,只需要建立Socket并绑定到相应端口,就可以收发数据了,并且很容易对多个客户机并发。依据上述流程图编写UDP服务端测试程序udp_server_test.c,交叉编译后下载到开发板上运行,具体代码如下:
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<unistd.h>
#include<errno.h>
#include<string.h>
#include<stdlib.h>#define SERVER_PORT 4321int main()
{int sock_fd; //套接字描述符int recv_num;int send_num;char recv_buf[20] = {0};char ack_buf[20] = "rev success";struct sockaddr_in addr_server; //服务端地址struct sockaddr_in addr_client; //客户端地址socklen_t client_addrlen;//AF_INET指定通信协议族(IPV4、IPV6等),SOCK_DGRAM表示socket类型(面向流的TCP取SOCK_STREAM,面向数据报的UDP取SOCK_DGRAM)//第三个参数protocol通常设置为0,表示为给定的通信域和套接字类型选择默认协议sock_fd = socket(AF_INET,SOCK_DGRAM,0); //创建套接字文件描述符,调用成功返回有效文件描述符,失败返回-1并设置errno if(sock_fd < 0){perror("socket error");exit(1);} else{printf("creat udp socket sucess\n");}//初始化服务器端地址memset(&addr_server,0,sizeof(struct sockaddr_in));addr_server.sin_family = AF_INET; //协议族addr_server.sin_port = htons(SERVER_PORT); //端口号,htons转换字节序// addr_serv.sin_addr.s_addr = htonl(INADDR_ANY); //任意本地址,服务器所有网卡IP地址addr_server.sin_addr.s_addr=inet_addr("192.168.2.136"); //IP地址字符串格式转换为二进制格式client_addrlen = sizeof(struct sockaddr_in);//绑定套接字if(bind(sock_fd,(struct sockaddr *)&addr_server,sizeof(struct sockaddr_in))<0 ) //第二个参数需要强转,成功返回0失败返回-1{perror("bind error");exit(1);} else{ printf("bind sucess\n");}while(1) //循环接收发送数据{printf("begin recv:\n");recv_num = recvfrom(sock_fd,recv_buf,sizeof(recv_buf),0,(struct sockaddr *)&addr_client,&client_addrlen);if(recv_num < 0){printf("recvfrom error\n");perror("again recvfrom");exit(1);} else{printf("recvfrom sucess,data len is %d:%s\n",recv_num,recv_buf);printf("begin send to ack:\n");send_num = sendto(sock_fd,ack_buf,sizeof(ack_buf),0,(struct sockaddr *)&addr_client,client_addrlen);if(send_num < 0){perror("sendto");exit(1);}else{printf("send ack success:%s\n",ack_buf);}} }close(sock_fd);return 0;
}
程序遵循以下流程:
① 建立套接字文件描述符,使用函数socket(),生成套接字文件描述符。
②设置服务器IP地址和端口,初始化要绑定的网络地址结构。
③绑定IP地址、端口等信息,使用bind()函数,将套接字文件描述符和一个地址进行绑定。
④循环接收客户端的数据,使用recvfrom()函数接收客户端的网络数据。
⑤向客户端发送数据,使用sendto()函数向服务器主机发送数据。
⑥关闭套接字,使用close()函数释放资源。UDP协议的客户端流程。
注:其中代码44行bind的调用并不复杂,关键在于需要对地址结构体指针进行转换,否则编译器会给出警告。绑定的地址和端口在36到40行填充sturct sockaddr_in结构完成的,服务器没有特殊要求的情况下,绑定地址用INADDR_ANY监听所有地址即可,另外要注意字节序的转换,这对于程序,尤其是要求可移植性的程序是一定要注意的。
测试环境搭建:PC机上使用SocketTool软件模拟UDP客户端,新建客户端设置对方IP为192.168.2.136,对方端口为4321(也就是开发板服务端IP和端口,对应程序中绑定的IP和端口) 。PC机修改IP地址为192.168.2.100保证与开发板在同一网段,开发板通过网线与PC机连接。至此测试环境搭建完成,接下来只需要在SocketTool刚刚新建的客户端上发送数据,开发板就能收到了。
设置重复发送10次间隔1s,如下图所示:
开发板上运行程序,完整的测试过程如下:
UDP服务端socket编程测试结果
3.2 UDP客户端编程和测试
对于UDP客户机,bind(2)和connect(2)都不是必须的,系统会自动隐式处理这两个过程。创建Socket后可直接使用sendto(2)发送数据到服务器,之后在这个Socket等待服务器回发的数据即可,但是因为UDP可能会丢包,需要设置超时,超时后还未数据到来则判断数据报已经丢失。此时应该报告超时后退出,而不应该始终等待下去,造成程序卡死。
UDP客户端的结构也很简单,创建Socket后完全省略了bind(2)和connect(2)的步骤直接调用sendto(2)发送数据到服务器,并等待回应即可。因为UDP有丢包可能,客户端如果不设置超时会在丢包时卡死,本例程调用select()函数设置超时,超时未接收到数据则认为数据丢失。具体实现代码如下:
#include<stdio.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<sys/select.h>
#include<netinet/in.h>#define DEST_PORT 60000
#define DSET_IP_ADDRESS "192.168.2.100"int main()
{int sock_fd; //套接字文件描述符int send_num;int recv_num;int i;socklen_t dest_len;char send_buf[20] = "hello yrr";char recv_buf[20] = {0};struct sockaddr_in addr_server; //服务端地址fd_set sockset; //定义文件描述符集合int ret;// struct timeval timeout = { //设置阻塞等待时间为3s// .tv_sec = 3,// };struct timeval timeout;sock_fd = socket(AF_INET,SOCK_DGRAM,0);//创建套接字//初始化服务器端地址memset(&addr_server,0,sizeof(addr_server));addr_server.sin_family = AF_INET;addr_server.sin_addr.s_addr = inet_addr(DSET_IP_ADDRESS);addr_server.sin_port = htons(DEST_PORT);dest_len = sizeof(struct sockaddr_in);printf("start send data 10 times:\n");for(i=0;i<10;i++){send_num = sendto(sock_fd,send_buf,sizeof(send_buf),0,(struct sockaddr *)&addr_server,dest_len);if(send_num < 0){perror("sendto");exit(1);} else //发出数据后等待服务端回复数据{printf("send success:%s ,waitting for server ack\n",send_buf);FD_ZERO(&sockset); //初始化文件描述符集合sockset为空FD_SET(sock_fd,&sockset); //添加UDP文件描述符timeout.tv_sec = 3; //必须每次都要重新赋值,否则下次时间为0不等待就返回timeout.tv_usec = 0; //不能少,否则一直阻塞ret = select(sock_fd+1,&sockset,NULL,NULL,&timeout);//阻塞直到文件描述符有数据可读才会返回,timeout设置阻塞时间if(ret<0) //返回-1表示有错误发生并设置errno{perror("select err");exit(1);}else if(ret == 0) //返回0表示在任何文件描述符成为就绪态之前select()调用已经超时{printf("waitting for server ack timeout\n");}else //返回正值表示处于就绪态的文件描述符的个数,可读了{printf("ret is %d,sock_fd have data to be read:\n",ret);if(FD_ISSET(sock_fd,&sockset)) //判断sock_fd文件描述符是否是集合中的成员{memset(recv_buf,0,sizeof(recv_buf));recv_num = recvfrom(sock_fd,recv_buf,sizeof(recv_buf),0,(struct sockaddr *)&addr_server,&dest_len);if(recv_num <0){perror("recvfrom");exit(1);} else{printf("recvfrom success:%s\n",recv_buf);}}}}}close(sock_fd);return 0;
}
程序遵循以下流程:
①建立套接字文件描述符,socket();
②初始化填充服务器IP地址和端口,struct sockaddr_in;
③循环10次向服务器发送数据,sendto();
④发送成功后调用select()函数阻塞等待,如果有数据到来就recvfrom()正常读取数据,超时未收到数据则认为数据丢失,继续下一次循环。
⑤关闭套接字,close()。
53行,超时设置为3秒。在55到83行是使用select(2)等待数据到来的代码,当select(2)出错时,需要判断errno;如果正常等到数据到来则正常读取数据;如果超时时间到,并未收到数据,则认为数据已经丢失,继续下一回合通信。
测试环境搭建:PC机上使用UDP_tester工具模拟UDP服务端,设置服务器端口为60000,PC机IP地址为192.168.2.100(程序需对应)。开发板IP地址为192.168.2.136保证与PC机在同一网段,开发板通过网线与PC机连接,至此测试环境搭建完成。
测试结果:开发板上运行程序循环10次发送字符串“hello yrr”到服务端,UDP_tester工具收到后会显示对方(客户端)IP和端口信息,如下图所示:
随后在工具下方窗口填写目的IP地址和端口,发送“jdp”,如下图所示,服务端就会发送数据到开发板,可在控制台看到相关打印信息。
完整测试过程如下:
UDP客户端socket编程测试结果
结果分析:可以看到,服务器若不在3s之内回送数据到客户端,select()函数会超时返回;运用UDP_tester工具手动回送数据可在开发板控制台上观察到接收到的数据打印。
测试中发现的问题:
①用SocketTool模拟服务端时接收不到开发板发来的数据,使用Wireshark抓包软件能抓到数据发到PC机上了,但不知何缘故SocketTool就是收不到。后用UDP_tester工具就可以接收到开发板发来的数据。
②调用select()函数设置超时退出时间为3s,一开始是代码26-28行定义timeout并初始化3s,循环调用时第一次不发数据是3s之后返回,后面9次均是瞬间退出,没有等待3s。后改进为代码53行每次循环开始重新赋值timeout的秒,不赋值微秒,现象依旧。后也重新赋值微秒,才达到了每次等待3s的效果。本人亲测使用select()函数可能会遇见上述问题,具体原因参考下一节对该函数的介绍。
3.3 select()函数
何为I/O多路复用?
I/O多路复用(IO multiplexing)它通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行I/O操作时,能够通知应用程序进行相应的读写操作。I/O多路复用技术是为了解决:在并发式I/O场景中进程或线程阻塞到某个I/O系统调用而出现的技术,使进程不阻塞于某个特定的I/O系统调用。
由此可知,I/O多路复用一般用于并发式的非阻塞I/O,也就是多路非阻塞I/O,比如程序中既要读取鼠标、又要读取键盘,多路读取。
我们可以采用两个功能几乎相同的系统调用来执行I/O多路复用操作,分别是系统调用select()和poll()。 这两个函数基本是一样的,细节特征上存在些许差别!
I/O多路复用存在一个非常明显的特征:外部阻塞式,内部监视多路 I/O。
系统调用select()可用于执行I/O多路复用操作,调用select()会一直阻塞,直到某一个或多个文件描述符成为就绪态(可以读或写)。使用该函数需要包含头文件<sys/select.h>,其函数原型如下所示:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数列表中:readfds、writefds以及exceptfds都是fd_set类型指针,指向一个fd_set类型对象,fd_set数据类型是一个文件描述符的集合体,所以参数readfds、writefds以及exceptfds都是指向文件描述符集合的指针,这些参数按照如下方式使用:
readfds是用来检测读是否就绪(是否可读)的文件描述符集合;
writefds是用来检测写是否就绪(是否可写)的文件描述符集合;
exceptfds是用来检测异常情况是否发生的文件描述符集合。
Linux提供了四个宏用于对fd_set类型对象进行操作,所有关于文件描述符集合的操作都是通过这四个宏来完成的:FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO(),稍后介绍。
如果对readfds、writefds以及exceptfds中的某些事件不感兴趣,可将其设置为NULL,这表示对相应条件不关心。如果这三个参数都设置为NULL,则可以将select()当做为一个类似于sleep()休眠的函数来使用,通过select()函数的最后一个参数timeout来设置休眠时间。
select()函数的第一个参数nfds通常表示最大文件描述符编号值加1,考虑readfds、writefds以及exceptfds这三个文件描述符集合,在3个描述符集中找出最大描述符编号值,然后加1,这就是参数nfds。select()函数的最后一个参数timeout可用于设定select()阻塞的时间上限,控制select的阻塞行为,可将timeout参数设置为NULL,表示select()将会一直阻塞、直到某一个或多个文件描述符成为就绪态;也可将其指向一个struct timeval结构体对象,如果参数timeout指向的struct timeval 结构体对象中的两个成员变量都为 0,那么此时select()函数不会阻塞,它只是简单地轮训指定的文件描述符集合,看看其中是否有就绪的文件描述符并立刻返回。否则,参数timeout将为select()指定一个等待(阻塞)时间的上限值,如果在阻塞期间内,文件描述符集合中的某一 个或多个文件描述符成为就绪态,将会结束阻塞并返回;如果超过了阻塞时间的上限值,select()函数将会返 回!select()函数将阻塞直到有以下事情发生:
①readfds、writefds或exceptfds指定的文件描述符中至少有一个称为就绪态;
②该调用被信号处理函数中断;
③参数timeout中指定的时间上限已经超时。
文件描述符集合的所有操作都可以通过FD_CLR()、FD_ISSET()、FD_SET()、FD_ZERO()这四个宏来完成,这些宏定义如下所示:
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
文件描述符集合有一个最大容量限制,有常量FD_SETSIZE来决定,在Linux系统下,该常量的值为1024。在定义一个文件描述符集合之后,必须用FD_ZERO()宏将其进行初始化操作,然后再向集合中添加我们关心的各个文件描述符,例如:
fd_set fset; //定义文件描述符集合
FD_ZERO(&fset); //将集合初始化为空
FD_SET(3, &fset); //向集合中添加文件描述符 3
FD_SET(4, &fset); //向集合中添加文件描述符 4
在调用select()函数之后,select()函数内部会修改readfds、writefds、exceptfds这些集合,当select()函数返回时,它们包含的就是已处于就绪态的文件描述符集合了。比如在调用select()函数之前,readfds所指向的集合中包含了 3、4这两个文件描述符,当调用select()函数之后,假设select()返回时,只有文件描述符4已经处于就绪态了,那么此时readfds指向的集合中就只包含了文件描述符 4。所以由此可知,如果要在循环中重复调用select(),我们必须保证每次都要重新初始化并设置readfds、writefds、exceptfds这些集合。
select()函数有三种可能的返回值,会返回如下三种情况中的一种:
①返回-1表示有错误发生,并且会设置errno。可能的错误码包括EBADF、EINTR、EINVAL、EINVAL以及ENOMEM,EBADF表示readfds、writefds或exceptfds中有一个文件描述符是非法的;EINTR表示该函数被信号处理函数中断了,其它错误可以自行网上查询,在man手册都有详细的记录。
②返回0表示在任何文件描述符成为就绪态之前select()调用已经超时,在这种情况下,readfds,writefds以及exceptfds所指向的文件描述符集合都会被清空。
③返回一个正整数表示有一个或多个文件描述符已达到就绪态。返回值表示处于就绪态的文件描述符的个数,在这种情况下,每个返回的文件描述符集合都需要检查,通过 FD_ISSET()宏进行检查, 以此找出发生的 I/O 事件是什么。如果同一个文件描述符在readfds,writefds以及exceptfds 中同时被指定,且它多于多个I/O事件都处于就绪态的话,那么就会被统计多次,换句话说,select()返回三个集合中被标记为就绪态的文件描述符的总数。
使用示例(参考注释):
int main()
{int ret;int buf[20];fd_set sockset; //定义文件描述符集合struct timeval timeout; //定义timeout设置阻塞时间……;……;……;while (1) {FD_ZERO(&sockset); //初始化文件描述符集合sockset为空 FD_SET(0, &sockset); //添加键盘FD_SET(fd, &sockset); //添加鼠标,fd位鼠标文件描述符timeout.tv_sec = 3; //必须每次都要重新赋值,否则下次时间为0不等待就返回timeout.tv_usec = 0; //不能少,否则一直阻塞ret = select(fd + 1, &sockset, NULL, NULL, &timeout); //检测两个文件描述符是否可读if (ret < 0) //select err{perror("select error");exit(-1);}else if (ret == 0) //超时返回{fprintf(stderr, "select timeout.\n");}else //返回正值表示处于就绪态的文件描述符的个数,此处为可读{}if(FD_ISSET(0, &sockset)) //检查键盘是否为就绪态,是返回true,否则返回false{ret = read(0, buf, sizeof(buf));}if(FD_ISSET(fd, &sockset)) //检查鼠标是否为就绪态,是返回true,否则返回false{ret = read(0, buf, sizeof(buf));}}return 0;
}