嵌入式linux入门3-3-套接字

socket主要函数

创建套接字

socket,我们一般翻译为套接字,其是一种通讯接口,允许位于不同计算机之间的线程通过网络进行通讯,我们可以使用socket系统调用来创建一个socket接口,此函数会返回一个定位此接口的文件描述符。

/**
 * 创建一个socket接口,并返回它的描述符,返回-1表示失败
 * 
 * domain参数用于指定接口使用的通讯域(communication domain),其支持AF_xxx形式的一组宏定义,
 * 对于网络编程,我们常用AF_INET(IPv4 Intternet protocols);
 * 对于机器内线程间的通讯,我们可以用AF_UNIX(Local communication);
 * 
 * type参数用于指定通讯方式(communication semantics),其支持SOCK_xxx形式的一组宏定义
 * 对于因特网而言,我们常用SOCK_STREAM(对应传输层TCP协议)以及SOCK_DGRAM(对应传输层UDP协议)
 * 
 * protocol参数用于指定通讯协议,一般情况下一种type只对应一种协议,所以一般赋值为0,
 * 在domain为AF_INET和AF_UNIX时,它总是0
 */
int socket(int domain, int type, int protocol)

以下引用说明了SOCK_STREAM与SOCK_DGRAM的一些差异:

  • 流套接字(SOCK_STREAM)
    流套接字(在某些方面类似于标准的输入/输出流)提供的时一个有序、可靠、双向字节流的连接。因此,发送的数据可以确保不会丢失、重复或者乱序到达,并且在这一过程中发生的错误也不会显示出来。大的消息将被分片、传输、再重组。
  • 数据报套接字(SOCK_DGRAM)
    与流套接字相反,由类型SOCK_DGRAM指定的数据报套接字不建立和维持一个连接。它对可以发送的数据报长度有限制。数据作为一个单独的网络消息被传输,它可能丢失、复制、或乱序到达。
    。。。。。。
    数据报适用于信息服务中的“单次”(single-shot)查询,主要用来提供日常状态信息或执行低优先级的日志记录。它的 优点是服务器的崩溃不会给客户造成不便,也不会要求客户重启,因为基于数据报的服务器通常不保留连接数据,所以它们可以在不打扰其客户的前提下停止并重启。

套接字地址

对于AF_UNIX域的套接字来说,其地址由如下结构体定义:

#include <sys/un.h>

/* Structure describing the address of an AF_LOCAL (aka AF_UNIX) socket.  */
struct sockaddr_un
{
    sa_family_t sun_family;
    char        sun_path[108];      /* Path name.  */
};

对于AF_INET域的套接字来说,其地址由如下结构体定义(只给出关键成员):

#include <netinet/in.h>

/* Structure describing an Internet socket address.  */
struct sockaddr_in
{
    sa_family_t        sin_family;
    unsigned short int sin_port;    /* Port number */
    struct in_addr     sin_addr;    /* Internet address */
}

/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
{
    in_addr_t s_addr;
};

TIPS!!!对于AF_INET的服务器来说,这里设置的地址为允许连接到服务器的地址。比如:如果只允许本地连接,设置为127.0.0.1,如果想与远端任意ip的计算机通讯,设置为INADDR_ANY。

命名套接字

对于服务器端来说,要想套接字可以被其他进程访问,必须将套接字进行命名,对AF_UNIX套接字命名会使之绑定到一个文件系统的路径名,而对AF_INET套接字命名则会使之关联到一个IP端口号。使用bind系统调用来对套接字进行命名:

#include <sys/socket.h>

/**
 * 命名套接字,成功返回0,失败返回-1并设置errno指示失败原因
 * sockfd    要绑定的套接字描述符
 * sockaddr  要绑定到的路径或地址,不同通讯域的结构体不同,传入前需要转换类型
 * addrlen   传入的sockaddr结构体的实际大小
 */
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

创建套接字队列

对于SOCK_STREAM类型的服务器来说,当完成绑定后,就可以使用listen系统调用创建一个队列来保存待处理的客户端请求。

#include <sys/socket.h>

/**
 * 创建套接字队列,成功返回0,失败返回-1并设置errno指示失败原因
 * sockfd 为套接字描述符
 * backlog 用于指定套接字队列的可以容纳的套接字个数
 */
int listen(int sockfd, int backlog);

接受连接

对于SOCK_STREAM类型的服务器来说,创建套接字队列后,服务器端调用accept系统调用开始接收来自客户端的连接,此系统调用会堵塞直到套接字队列中出现请求连接的客户,之后一个新的套接字将会被创建用于与客户进行通讯。

在调用accept前,套接字必须先使用bind命名并使用listen创建套接字队列。

#include <sys/socket.h>

/**
 * 堵塞在此函数并等待一个新连接到来(所以只用于基于连接的协议),
 * 如果有新的连接到来,返回与此连接通讯的套接字,如果出现错误,返回-1并设置errno
 * scokfd   服务器连接描述符
 * addr     输出客户的地址
 * addrlen  输出客户地址结构体的实际长度,传入时要初始化为传入的addr的长度
 */
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

如果不希望堵塞在此函数,可以通过设置fcntl系统调用设置标志位来改变此行为:

int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, O_NONBLOCK | flags);

这样一来,accept函数会在没有待处理的连接时也立刻返回,此时返回值返回-1。

请求连接

客户程序可以通过一个未命名(无需绑定地址)的套接字来连接服务器的监听套接字,connect用于完成此项工作。

/**
 * 将sockfd连接到由addr指定的服务器,addr的大小由addrlen指定
 * 成功时返回0,否则返回-1并设置errno
 */
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

如果连接无法立刻建立,connect调用将堵塞一段不确定的时间,一旦超时时间到达,连接将被放弃,connect调用失败。如果connect调用期间被信号打断且该信号得到了处理,connect调用依然会返回失败(errno设置为EINTR),但是此时连接尝试依然在进行,应用程序应该在之后检查连接是否成功。

与前面得accept函数一样,如果不希望堵塞在此函数,可以通过设置fcntl系统调用设置标志位来改变此行为:

int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, O_NONBLOCK | flags);

这样一来,如果连接不成功,connect将会返回失败并设置errno为EINPROGRESS,并以后台异步的方式继续尝试连接,程序后续可以通过select来判断是否已经连接成功。

读写数据

建议看这篇文章:https://www.programminghunter.com/article/90651003286/

简单来说:

  • 对于TCP这种基于连接的通讯协议,由于连接建立时已经明确了通讯双方的地址,所以常用write/read, send/recv,不需要在收发时指明目标地址;
  • 对于UDP这种无连接的通讯协议,由于没有提前明确通讯双方的地址,所以常用sendto/recvfrom,需要在收发时指明目标地址;

关闭套接字

通过close函数来终止服务器和客户端的套接字连接,就如同关闭文件一样,重要的是关闭的时机:

  • 对于客户端,在使用完后就可以关闭套接字了,对于TCP等基于连接的传输协议,这会发出断开连接的请求给服务器。
  • 对于服务器,应该在read调用返回0的时候关闭套接字,这表明客户端发起了关闭请求,此时服务器再close自己的套接字就可以保证两边的连接均断开了。

需要注意,对于基于连接的通讯协议,在设置了SOCK_LINGER选项的情况下,由于断开连接还需要进行一些通讯流程,close函数会在该套接字还有数据待传输时堵塞直到数据传输完成。

Socket连接流程

函数调用流程

  • 对于诸如使用TCP协议的SOCK_STREAM服务器来说,使用socket接口的主要流程为:
    创建套接字(socket)->命名套接字(bind)->创建监听队列(listen)->接收客户端连接(accept)->读写数据(read/write)->关闭连接(close)
  • 对于诸如使用TCP协议的SOCK_STREAM客户端来说,使用socket接口的主要流程为:
    创建套接字(socket)->请求连接(connect)->读写数据(read/write)->关闭连接(close)

  • 对于诸如使用UDP协议的SOCK_DGRAM服务器来说,使用socket接口的主要流程为:
    创建套接字(socket)->命名套接字(bind)->读写数据(recvfrom/sendto)->关闭连接(close)
  • 对于诸如使用UDP协议的SOCK_DGRAM客户端来说,使用socket接口的主要流程为:
    创建套接字(socket)->读写数据(recvfrom/sendto)->关闭连接(close)

基于TCP协议的Socket通讯示例

在编写程序时,以下两点需要注意:

  • 我们在选择通讯用的端口号时,需要避开小于1024的端口号(预留的端口号)以及/etc/services中已存在的端口号。
  • 需要注意主机字节序到网络字节序的转换问题,使用#include<netinet/in.h>中的转换函数进行转换。
/**
 * @file server.c
 * @brief TCP echo server demo
 *        接收客户端的信息然后原样发回去
 * @version 0.1 初始版本
 *
 */

#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

const in_port_t server_port = 1234; /* 监听的端口号 */
const int sequeue_len = 10;         /* 监听队列的长度 */

int main(int args, char *argv[])
{
    int sockfd;              /* 监听用套接字 */
    struct sockaddr_in addr; /* 地址 */

    /* 创建套接字 */
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1)
    {
        perror("SERVER: Unable to create socket - ");
        exit(-1);
    }

    /* 命名套接字 */
    bzero((void *)&addr, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 注意主机到网络的字节序转换 */
    addr.sin_port = htons(server_port);       /* 注意主机到网络的字节序转换 */
    if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) == -1)
    {
        perror("SERVER: Unable to bind socket - ");
        close(sockfd); /* 关闭套接字 */
        exit(-1);
    }

    /* 建立套接字队列 */
    if (listen(sockfd, sequeue_len) == -1)
    {
        perror("SERVER: Unable to listen socket - ");
        close(sockfd); /* 关闭套接字 */
        exit(-1);
    }

    while (1)
    {
        int client_sockfd;              /* 连接后通讯的套接字 */
        struct sockaddr_in client_addr; /* 客户端地址 */
        socklen_t client_addrlen;       /* 客户端地址结构体长度 */
        char buffer[100];               /* 数据缓冲 */
        ssize_t client_recvlen;         /* 客户端接收长度 */

        /* 处理客户端的连接请求 */
        client_addrlen = sizeof(client_addr);
        client_sockfd = accept(sockfd, 
                               (struct sockaddr *)&client_addr, &client_addrlen);
        if (client_sockfd == -1)
        {
            perror("SERVER: Unable to accept socket - ");
            close(sockfd);
            exit(-1);
        }

        /* 接收用户的数据并回发 */
        while ((client_recvlen = read(client_sockfd, buffer, 100)) > 0)
        {
            if (write(client_sockfd, buffer, client_recvlen) == -1)
            {
                perror("SERVER: Unable to write socket - ");
                break;
            }
        }

        /* 到这里说明收到了客户端断开连接的请求或者发生了错误 */
        close(client_sockfd); /* 关闭与客户端通讯的套接字 */
    }

    /* 按理说这里永远不会执行到 */
    exit(0);
}
/**
 * @file client.c
 * @brief TCP hello client demo
 *        向服务器发送一次hello world然后关闭连接
 * @version 0.1 初始版本
 *
 */

#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

const char *server_ip = "127.0.0.1";
const in_port_t server_port = 1234;

int main(int args, char *argv[])
{
    int sockfd;                    /* 套接字 */
    struct sockaddr_in sockaddr;   /* 地址 */
    char str[] = "hello world!\n"; /* 待发送字符串 */
    int recvlen;                   /* 接收数据长度 */

    /* 创建socket连接 */
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1)
    {
        perror("CLIENT: Unable to create socket - ");
        exit(-1);
    }

    /* 连接服务器 */
    bzero(&sockaddr, sizeof(sockaddr));
    sockaddr.sin_family = AF_INET;
    sockaddr.sin_addr.s_addr = inet_addr(server_ip);
    sockaddr.sin_port = htons(server_port);
    if (connect(sockfd, (struct sockaddr *)&sockaddr, sizeof(sockaddr)) == -1)
    {
        perror("CLIENT: Unable to connect server - ");
        close(sockfd);
        exit(-1);
    }

    /* 发送hello world */
    if (write(sockfd, str, sizeof(str)) == -1)
    {
        perror("CLIENT: Unable to write socket - ");
        close(sockfd);
        exit(-1);
    }

    /* 接收服务器回发的数据并打印出来 */
    bzero(str, sizeof(str));
    if ((recvlen = read(sockfd, str, sizeof(str))) > 0)
    {
        printf("%s", str);
    }

    /* 关闭连接 */
    close(sockfd);

    exit(0);
}

以上demo实现了一个基本的服务器和客户端交互演示,但是对于服务器来说,存在一个很大的缺陷:

  1. 此服务器demo一次只能服务一个客户端,下一个客户端在上一个客户端断开连接后才能得到服务,如果某个客户端一直占用服务器,则其他客户端将无法及时得到服务器回复,此demo将会在后面"同时处理多个客户端连接"这一章节中被改进。

基于UDP协议的Socket通讯示例

使用UDP协议编写服务器和客户端会简便很多,对于不要求数据可靠性的场合,UDP协议可能会是个好的选择:

以下UDP服务器示例实现了类似daytime服务的功能:

/**
 * @file datagram_server.c
 * @author zdk
 * @brief UDP数据报协议服务器demo
 *        此服务器会在客户端发送任意信息后回发当前的系统日期与时间
 * @version 0.1 初始版本
 * @date 2022-04-01 初始版本
 *
 */

#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>

const in_port_t server_port =  3241;

int main(int argc, char *argv[])
{
    int sockfd;
    struct sockaddr_in server_addr;
    struct sockaddr_in client_addr;
    socklen_t client_addr_len;
    char *buffer[128];
    ssize_t recvlen;
    ssize_t sendlen;

    /* 创建socket套接字 */
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd == -1)
    {
        perror("Unable to create socket - ");
        exit(-1);
    }

    /* 命名套接字 */
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(server_port);
    if (bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
    {
        perror("Unable to bind socket - ");
        close(sockfd);
        exit(-1);
    }

    /* 接收客户端发来的消息,之后回发日期时间字符串 */
    client_addr_len = sizeof(client_addr);
    while (recvlen = recvfrom(sockfd, buffer, sizeof(buffer), 0, 
                              (struct sockaddr *)&client_addr, &client_addr_len))
    {
        if (recvlen == -1)
        {
            perror("Receive from client error - ");
        }
        else if (client_addr_len == sizeof(client_addr)) /* 验证地址长度 */
        {
            time_t timer;
            char *time_str;
            struct tm *now_time;

            /* 生成日期时间字符串 */
            timer = time(NULL);
            now_time = localtime(&timer);
            time_str = asctime(now_time);

            /* 回发给客户端 */
            if (sendto(sockfd, time_str, strlen(time_str), 0, 
                       (struct sockaddr *)&client_addr, client_addr_len) == -1)
            {
                perror("Unable send data to client - ");
            }
        }
        else
        {
            /* 复位地址长度 */
            client_addr_len = sizeof(client_addr);
        }
    }

    /* 应该不会执行到这 */
    close(sockfd);
    exit(0);
}

如下客户端向服务器发送任意报文然后打印返回的时间日期信息:

/**
 * @file datagram_client.c
 * @author zdk
 * @brief 访问本地的3241端口以从UDP服务器获取当前的时间和日期
 * @version 0.1 初始版本
 * @date 2022-04-01 初始版本
 *
 */

#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

const in_port_t server_port =  3241;

int main(int args, char *argv[])
{
    int sockfd;                  /* 套接字描述符 */
    struct sockaddr_in sockaddr; /* 连接地址 */
    char buffer[128];            /* 接收数据的缓冲 */
    ssize_t recvlen;                 /* 接收长度 */

    /* 创建socket套接字 */
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd == -1)
    {
        perror("Unable to create socket - ");
        exit(-1);
    }

    /* 向服务器发送任意报文获取时间和日期字符串 */
    bzero(&sockaddr, sizeof(sockaddr));
    sockaddr.sin_family = AF_INET;
    sockaddr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    sockaddr.sin_port = htons(server_port);
    if (sendto(sockfd, buffer, 1, 0, (struct sockaddr *)&sockaddr, sizeof(sockaddr)) == -1)
    {
        perror("Unable to send data - ");
        exit(-1);
    }

    /* 接收服务器返回的字符串 */
    recvlen = recvfrom(sockfd, buffer, sizeof(buffer), 0, NULL, NULL);
    if (recvlen == -1)
    {
        perror("Unable to receive data - ");
        exit(-1);
    }

    /* 打印时间日期 */
    buffer[recvlen] = '\0';
    printf("%s", buffer);

    /* 关闭连接并退出 */
    close(sockfd);
    exit(0);
}

执行结果:

water@LAPTOP-Q3TGG09O:~/lichee_nano/code/socket$ ./datagram_server &
[1] 10527
water@LAPTOP-Q3TGG09O:~/lichee_nano/code/socket$ ./datagram_client 
Fri Apr  1 21:50:28 2022

socket选项设置

我们可以使用套接字选项来在各个层次控制套接字的行为,以下函数用于设置套接字选项:

/**
 * 设置套接字选项,成功返回0,失败返回-1并设置errno
 * sockfd 套接字描述符
 * level 在哪个层次进行选项设置,如果只想对当前套接字生效,则设置为SOL_SOCKET,
 *       如果想在整个协议层面生效,则设置为协议对应的协议号,可以通过函数getprotobyname获得
 * optname 指定要设置的选项,
 * optval 选项配置信息,
 * optlen optval的长度
 */
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

常用的选项如下:

选项 说明
SO_DEBUG 打开调试信息
SO_KEEPALIVE 通过定期传输保活报文来防止服务器关闭连接
SO_LINGER 在close调用返回前完成传输工作

SO_DEBUG和SO_KEEPALIVE用一个整数optval值表示开(1)或关(0),而SO_LINGER需要使用一个定义在/sys/socket.h中的linger结构来定义选项状态以及套接字关闭前的拖延时间。

网络信息函数

符号化端口号

在有权限的情况下,我们可以将自己的服务器端口号添加到/etc/services中,给自己的端口号起个容易记住的名字,这样这台机器上的客户端在连接服务器时就可以直接用名字代替端口号了:

比如在/etc/services中的# Local services本地服务类别下添加以下内容:

# Local services
echo_demo       1234/tcp                        # my echo demo service

则可以通过以下网络信息函数来获取端口号:

#include <netdb.h>

/* 查找proto通讯协议下名为name的服务,并返回信息结构体指针servent,查找失败返回NULL */
/* proto为NULL代表匹配所有通讯协议 */
struct servent *getservbyname(const char *name, const char *proto);

/* 查找proto通讯协议下端口号为port的服务,并返回信息结构体指针servent,查找失败返回NULL */
/* proto为NULL代表匹配所有通讯协议 */
struct servent *getservbyport(int port, const char *proto);

返回的信息结构体如下:

 struct servent {
               char  *s_name;       /* official service name */
               char **s_aliases;    /* alias list */
               int    s_port;       /* port number */
               char  *s_proto;      /* protocol to use */
           }

如下示例尝试从服务列表中获取tcp服务echo_demo的端口号

/* 尝试从/etc/services服务列表中获取端口号 */
struct servent *server_entry;
server_entry = getservbyname("echo_demo", "tcp");
if (server_entry != NULL)
{
    /* 获取成功,打印获取到的信息 */
    printf("GET_SERVER: %s/%s %d\n", 
           server_entry->s_name, server_entry->s_proto, ntohs(server_entry->s_port));
}

结果如下:

GET_SERVER: echo_demo/tcp 1234

符号化IP地址

与端口号的符号化差不多,在有权限的情况下,向/etc/hosts文件里添加自己希望符号化的IP地址,之后便可以使用如下函数来获取IP地址了:

/**
 * 返回包含主机信息的hostent结构体指针,c查找失败返回NULL
 * type 可以指定为AF_INET或AF_INET6
 * len为addr结构体的长度
 * addr为地址结构体指针,比如对于AF_INET,需要传入struct in_addr *
 */
struct hostent *gethostbyaddr(const void *addr, size_t len, int type);

/**
 * 返回包含主机信息的hostent结构体指针,查找失败返回NULL
 * name为hosts文件中的主机名
 */
struct hostent *gethostbyname(const char *name);

返回的信息结构体如下:

struct hostent {
               char  *h_name;            /* official name of host */
               char **h_aliases;         /* alias list */
               int    h_addrtype;        /* host address type */
               int    h_length;          /* length of address */
               char **h_addr_list;       /* list of addresses */
           }

如下示例尝试获取本机的ip地址:

/* 尝试从/etc/hosts主机列表中获取本地主机的ip地址 */
struct hostent *host_entry;
host_entry = gethostbyname("localhost");
if (host_entry != NULL)
{
    /* 获取成功,打印获取到的信息 */
    char **addrs = host_entry->h_addr_list;
    while (*addrs != NULL)
    {
        printf("GET_HOST: %s %s\n", 
               host_entry->h_name, inet_ntoa(*(struct in_addr *)*addrs));
        addrs++;
    }
}

获取结果如下:

GET_HOST: localhost 127.0.0.1

使用示例

本示例尝试通过符号化的IP地址和端口号访问本机的daytime服务,我们可以在/etc/services列表中找到这个服务的信息:

daytime         13/tcp
daytime         13/udp

此服务支持通过TCP/UDP协议访问,端口号为13,服务名称为daytime。在使用此服务之前,先要确保此服务已经启动。使用如下方式安装xinetd网络服务:

sudo apt install xinetd # xinetd - the extended Internet services daemon

之后打开/etc/xinetd.d/daytime文件,将其中的disable项修改为no以使能daytime服务,修改后重启服务以使之生效:

sudo service xinetd restart

所有准备工作就完成了,下面是客户端示例代码:

/**
 * @file daytime_client.c
 * @author zdk
 * @brief 访问任何已知主机的daytime服务来获取当前的日期时间信息
 *        基于tcp协议的daytime服务会监听13端口,
 *        并在客户端建立连接后返回ASCII码形式的时间信息,随后关闭连接
 * @version 0.1 初始版本
 * @date 2022-04-01 初始版本
 *
 */

#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

int main(int args, char *argv[])
{
    char *hostname = NULL;       /* 主机名 */
    struct hostent *hostinfo;    /* 主机信息 */
    struct in_addr *inaddr;      /* 指向ip地址 */
    struct servent *servinfo;    /* 服务信息 */
    int sockfd;                  /* 套接字描述符 */
    struct sockaddr_in sockaddr; /* 连接地址 */
    char buffer[128];            /* 接收数据的缓冲 */
    int readlen;                 /* 读取长度 */

    /* 获取传入的主机名称 */
    if (args >= 2)
    {
        hostname = argv[1];
    }
    else
    {
        fprintf(stderr, "Usage: %s hostname \n", argv[0]);
        exit(-1);
    }

    /* 查找主机名对应的ip地址 */
    hostinfo = gethostbyname(hostname);
    if (hostinfo == NULL || hostinfo->h_addrtype != AF_INET)
    {
        fprintf(stderr, "Unable to get host %s\n", hostname);
        exit(-1);
    }

    /* 获取daytime服务的端口号 */
    servinfo = getservbyname("daytime", "tcp");
    if (servinfo == NULL)
    {
        fprintf(stderr, "Unable to get daytime server\n");
        exit(-1);
    }

    /* 创建socket套接字 */
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1)
    {
        perror("Unable to create socket - ");
        exit(-1);
    }

    /* 连接daytime服务 */
    inaddr = (struct in_addr *)*hostinfo->h_addr_list;
    bzero(&sockaddr, sizeof(sockaddr));
    sockaddr.sin_family = AF_INET;
    sockaddr.sin_addr.s_addr = inaddr->s_addr;
    sockaddr.sin_port = (in_port_t)servinfo->s_port;
    if (connect(sockfd, (struct sockaddr *)&sockaddr, sizeof(sockaddr)) == -1)
    {
        perror("Unable to connet daytime server - ");
        close(sockfd);
        exit(-1);
    }

    /* 读取数据 */
    readlen = read(sockfd, buffer, sizeof(buffer));
    if (readlen == -1)
    {
        perror("Unable to read date - ");
        close(sockfd);
        exit(-1);
    }
    buffer[readlen] = '\0';

    /* 打印结果 */
    printf("%s", buffer);

    /* 关闭套件字并退出 */
    close(sockfd);
    exit(0);
}

执行结果:

water@LAPTOP-Q3TGG09O:~/lichee_nano/code/socket$ ./daytime_client localhost
01 APR 2022 16:31:18 CST

同时处理多个客户端连接

IO多路复用

linux通过select、poll、epoll这三个系统调用提供IO多路复用功能,IO多路复用,说白了就是你指定给系统一个范围的文件描述符,系统会帮你盯着这个范围内的文件描述符,当任何一个文件描述符可用时(有输入到达或者上一次输出完成),系统会通知你,这时你再去对对应的文件描述符进行操作即可。


继续之前建议先阅读以下文章:

100%弄明白5种IO模型

深入浅出理解select、poll、epoll的实现


一般来说,select和poll适用于需要同时处理的连接比较少的情况,而epoll用于大量并发连接的情况,我们这里使用select系统调用对前面的TCP协议Socket通讯示例进行改造,首先来介绍下主要的函数和宏定义:

#include <sys/select.h>

/**
 * 功能:
 * 堵塞在此函数,直到监测范围的文件描述符中,有文件描述符可读/可写/异常
 * select函数最多可以监测范围为[0:FD_SETSIZE)的文件描述符,
 * FD_SETSIZE宏定义一般为1024,也就是最多监测1024个文件描述符。
 * 
 * 参数说明:
 * nfds,     [IN]  此参数用于指定需要监测的描述符范围,如果设置nfds为数值N,则[0:N-1]区间的文件描述符将被监测;
 * readfds,   [IN/OUT] 传入时,如果此参数不为NULL,监控传入时此集合中标志位置1的文件描述符,
 *                     返回时,被监控的文件描述符如果可读,则对应的标志位会被置位;
 * writefds,  [IN/OUT] 与readfds一样,只不过监控是否可写;
 * exceptfds, [IN/OUT] 与readfds一样,只不过监控是否出现异常;
 * timeout,   [IN]  用于指定最长的超时时长,设为NULL表示一直监测直到有文件描述符标志位置位;
 * 
 * 返回:
 * 如果出现错误,返回-1;如果超时,返回0;否则返回被置位的文件描述符总个数。
 */
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

fd_set为要监听的描述符集合,linux以结构体方式定义此集合,并提供了一组用于操作此集合的宏定义:

/* Access macros for `fd_set'.  */
#define FD_SET(fd, fdsetp)    __FD_SET (fd, fdsetp)   /* 设置描述符fd的标志位 */
#define FD_CLR(fd, fdsetp)    __FD_CLR (fd, fdsetp)   /* 清空描述符fd的标志位 */
#define FD_ISSET(fd, fdsetp)  __FD_ISSET (fd, fdsetp) /* 判断描述符fd标志位是否置位 */
#define FD_ZERO(fdsetp)       __FD_ZERO (fdsetp)      /* 清空整个描述符集合的标志位 */

select的使用还是比较麻烦的,主要是函数的很多参数既要作为输入,又要作为输出,以下使用流程以监控多个描述符是否可读为例:

  1. 准备两个fd_set集合,一个用来标记我们要监控的描述符称为testfds,一个用来接收返回的标志位称为readfds;
  2. 使用FD_ZERO复位testfds,然后使用FD_SET将我们需要监控的文件描述符标志位置1;
  3. 将testfds复制给readfds,并将readfds传入select函数进行监控;
  4. select函数返回后,遍历所有readfds中的标志位,使用FD_ISSET进行标志位判断,并对标志位置1的文件描述符进行操作;
  5. 可以用FD_SET向testfds新增要监测的描述符,或者用FD_CLR清除描述符;
  6. 返回步骤3继续执行;

改进后的echo服务器

/**
 * @file server.c
 * @brief TCP echo server demo using select
 *        接收客户端的信息然后原样发回去
 * @version 0.1 初始版本
 *
 */

#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>

const in_port_t server_port = 1234; /* 监听的端口号 */
const int sequeue_len = 10;         /* 监听队列的长度 */

void add_client(int server_sockfd, fd_set *testfds)
{
    int client_sockfd;              /* 连接后通讯的套接字 */
    struct sockaddr_in client_addr; /* 客户端地址 */
    socklen_t client_addrlen;       /* 客户端地址结构体长度 */

    /* 处理客户端的连接请求 */
    client_addrlen = sizeof(client_addr);
    client_sockfd = accept(server_sockfd, (struct sockaddr *)&client_addr, &client_addrlen);
    if (client_sockfd == -1)
    {
        perror("SERVER: Unable to accept socket - ");
    }
    else
    {
        printf("SERVER: client %d online\n", client_sockfd);

        /* 将客户端通讯的套接字描述符加入要监听的描述符集合 */
        FD_SET(client_sockfd, testfds);
    }
}

void handle_client(int client_sockfd, fd_set *testfds)
{
    ssize_t client_recvlen; /* 客户端接收长度 */
    char buffer[100];        /* 数据缓冲 */

    /* 接收用户的数据并回发 */
    if ((client_recvlen = read(client_sockfd, buffer, 100)) > 0)
    {
        if (write(client_sockfd, buffer, client_recvlen) == -1)
        {
            perror("SERVER: Unable to write socket - ");
            close(client_sockfd);

            /* 将客户端通讯的套接字描述符踢出要监听的描述符集合 */
            FD_CLR(client_sockfd, testfds);
        }
    }
    else
    {
        if (client_recvlen == -1)
        {
            perror("SERVER: Unable to read socket - ");
        }

        /* 收到了客户端断开连接的请求或者读取发生了错误,断开与客户的连接 */
        printf("SERVER: client %d offline\n", client_sockfd);
        close(client_sockfd);

        /* 将客户端通讯的套接字描述符踢出要监听的描述符集合 */
        FD_CLR(client_sockfd, testfds);
    }
}

int main(int args, char *argv[])
{
    int server_sockfd;       /* 监听用套接字 */
    struct sockaddr_in addr; /* 地址 */
    fd_set testfds;          /* 要监听的描述符集合 */

    /* 创建套接字 */
    server_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_sockfd == -1)
    {
        perror("SERVER: Unable to create socket - ");
        exit(EXIT_FAILURE);
    }

    /* 命名套接字 */
    bzero((void *)&addr, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 注意主机到网络的字节序转换 */
    addr.sin_port = htons(server_port);       /* 注意主机到网络的字节序转换 */
    if (bind(server_sockfd, (struct sockaddr *)&addr, sizeof(addr)) == -1)
    {
        perror("SERVER: Unable to bind socket - ");
        close(server_sockfd); /* 关闭套接字 */
        exit(EXIT_FAILURE);
    }

    /* 建立套接字队列 */
    if (listen(server_sockfd, sequeue_len) == -1)
    {
        perror("SERVER: Unable to listen socket - ");
        close(server_sockfd); /* 关闭套接字 */
        exit(EXIT_FAILURE);
    }

    /* 复位标志位集合 */
    FD_ZERO(&testfds);

    /* 将服务器监听套接字描述符加入select的描述符集合 */
    FD_SET(server_sockfd, &testfds);

    while (1)
    {
        fd_set readable_fds; /* 可读描述符的标志位集合 */
        int readable_num;    /* 记录select返回值 */

        /* 等待描述符存在可读信息 */
        memcpy(&readable_fds, &testfds, sizeof(readable_fds));
        readable_num = select(FD_SETSIZE, &readable_fds, NULL, NULL, NULL);

        /* 根据select的返回值进行处理 */
        if (readable_num == -1)
        {
            perror("SERVER: select error - ");
            exit(EXIT_FAILURE);
        }
        else if (readable_num > 0)
        {
            /* 存在可读的套接字描述符,遍历他们并进行处理 */
            for (int fd = 0; fd < FD_SETSIZE && readable_num > 0; fd++)
            {
                if (FD_ISSET(fd, &readable_fds))
                {
                    /* 减少待处理的描述符数目 */
                    readable_num--;

                    if (fd == server_sockfd)
                    {
                        /* 监听套接字上存在可读信息,说明有新的客户试图连接 */
                        add_client(server_sockfd, &testfds);
                    }
                    else
                    {
                        handle_client(fd, &testfds);
                    }
                }
            }
        }
    }

    /* 按理说这里永远不会执行到 */
    exit(EXIT_SUCCESS);
}

这里有个改进点:可以通过记录最大文件描述符到一个max_fd变量的方式来确定需要select监控的区域,这样可以提高select的效率:

修改前:

readable_num = select(FD_SETSIZE, &readable_fds, NULL, NULL, NULL);

修改后:

readable_num = select(max_fd + 1, &readable_fds, NULL, NULL, NULL);

同时后面遍历并处理文件描述符时,也可以用类似的方法改进以减少遍历花费的时间。

发表评论

您的电子邮箱地址不会被公开。