CSAPP 第十一章 套接字接口函数

作者 柚爸

套接字接口函数一般都是由系统实现, 所以必须掌握, 应用程序全部要使用系统调用来进行套接字操作.

  1. 通用 – socket
  2. 客户端 – connect
  3. 服务端 – bind
  4. 服务端 – listen
  5. 服务端 – accept
  6. getaddrinfo
  7. getnameinfo
  8. open_clientfd
  9. open_listenfd

socket

socket函数用来创建套接字描述符, 无论服务器还是客户端都需要先创建套接字才行.

#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

其中的三个参数如下:

  1. domain 表示主机名称, AF_INET表示使用32位IP地址. 之前的Head First C 中使用了PF_INET. 这个见下边详述.
  2. type 表示套接字类型, 每个协议支持的套接字类型不同, 对于TCP/IP的连接固定使用 SOCK_STREAM 这个宏定义.
  3. protocol 是协议编号.

关于socket函数的详情可以看这里. AF指的是地址簇, 而PF指的是协议簇. TCP/IP的设计者原想是一个地址簇对应多个协议簇, 但是目前一个地址簇只有一个协议簇, 一个协议簇也只有一个协议, 因此第一个参数用AF_INET和PF_INET没什么区别, 而最后一个参数也总是0.

第二个参数会根据类型有所变动, 比如UDP协议就需要写成SOCK_DGRAM.

这个函数返回一个非负的套接字描述符, 如果出错为-1.

这个函数返回的socket描述符, 并没有分配一个地址+端口的套接字地址, 所以无法读写. 无论是服务器还是客户端都需要进一步工作.

客户端 – connect

客户端在执行完socket函数之后要做的工作是执行connect函数:

#include <sys/socket.h>

int connect(int sockfd, conststruct sockaddr *addr, socklen_t addrlen);

这个函数成功的时候返回0, 失败的时候返回-1.

其参数如下:

  1. sockfd, 指的是套接字描述符, 即刚才使用socket函数的返回值.
  2. addr, 指向服务器套接字地址数据结构的指针, 注意这是通用的 sockaddr 类型的指针, 具体使用的时候需要转换成 sockaddr_in 类型的指针.
  3. addrlen, 这个指的是前边套接字地址数据结构的大小, 就是 sizeof(sockaddr_in)

addr 指针指向的套接字地址数据结构, 是服务器的地址+端口号的组合. 这个函数的本质是向服务器发起TCP/IP协议的三次握手, 在握手成功或者失败之后返回.

在建立连接的过程中, 总有一方要先发送数据, 客户端的 connect 函数就是这个先发送数据的函数.

如果成功, 第一个参数 sockfd 套接字描述符, 此时就具备了读写的功能, 也就是说这个套接字描述符已经成为了一个连接的本机端点. 另外一个端点则在服务器端被创建.

服务端 – bind

为何要调用 bind 函数, 来自于之前概念中说的客户端与服务端的不同. 客户端socket的地址和端口号是由操作系统分配的, 在去连接服务端的时候, 这个工作已经由操作系统做好了, 所以客户端此时知道了套接字对, 可以发起连接了.

而服务端的套接字对中的两个套接字地址还都不知道. 首先要确定服务使用哪个端口, 所以必须手工指定一下.

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr* myaddr, socklen_t addrlen)

这个函数成功返回0, 不成功返回-1. 其参数解释如下:

  1. sockfd, 服务器调用 socket 函数创建的套接字描述符.
  2. myaddr, 指向服务器自己的套接字地址数据结构的指针.
  3. addrlen, 是 sizeof(myaddr)

这个函数的本质, 就是把套接字描述符和服务器端的套接字地址结构联系起来. 但是依然不能读写, 因为客户端的连接没有进来, 因此还不知道完整的套接字对.

由于每次要使用一个sockaddr类型也比较麻烦, Linux中有一个getaddrinfo可以方便的获取这个结构, 因此connect和bind函数经常依赖这个getaddrinfo来提供参数.

服务端 – listen

#include <sys/socket.h>

int listen(int sockfd, int backlog)

listen成功返回0, 失败返回-1. 其参数如下:

  1. sockfd, 经过socket和bind调用之后的套接字描述符
  2. backlog, 排队等待的连接数量

一般来说listen函数必须在socket和bind调用之后, accept调用之前来调用. 原因是通过socket函数创建的套接字, 默认是一个主动套接字, 即调用connect发起连接的客户端套接字.

针对这个套接字描述符调用listen之后, 就将其转变成了被动等待连接的服务端套接字.

至于backlog函数, 需要以后看UNIX网络编程第一卷了. 可以将其设置为一个比较大的数字, 比如1024.

服务端 – accept

这个函数简单的说, 就是调用后进入阻塞, 等待客户端连接:

#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)

其参数如下:

  1. sockfd, 经过socket, bind, listen 之后的套接字描述符
  2. addr, 注意, 这个指针不再是读取数据只用, 而是将客户端的套接字地址数据写入到指针指向的 sockaddr 结构中.
  3. addrlen, 注意, 这不像上边几个函数是int, 而是int指针, 是将客户端的套接字地址结构的长度写入到这个指针指向的int变量中.

这个函数实际上是从已完成连接的队列里返回下一个已经完成的连接, 队列没有连接, 就一直阻塞(进程睡眠, 是默认行为).

需要特别注意的是这个函数的返回值, 返回的是一个新的描述符, 叫做已连接套接字描述符. 而作为参数的sockfd, 叫做监听套接字描述符.

每个成功连接都会返回一个新的已连接套接字描述符, 而监听套接字描述符始终只有一个, 就是服务端调用socket产生的描述符.

所以对于一个服务(不是服务器)来说, 只有一个监听套接字, 内核会每来一个连接, 就在内部维护一个连接队列, 然后返回和这个连接对应的已连接套接字描述符.

很显然, 在客户端运行connect的时候, 服务端必须已经完成了从执行socket到accept的全部过程, 这样connect才能对accpet投怀送抱, 然后创建出一个连接, 连接在客户端的端点是主动套接字描述符, 在服务端的端点是已连接套接字描述符.

客户端和服务端的读写就可以通过各自的描述符进行读写了. 根据客户端-服务端模型, 客户端在连接完毕之后的工作是将响应发送给服务端, 服务端则是立刻进入等待接收响应的状态.

getaddrinfo

上边的所有socket系列函数都与 sockaddr 结构有关, 这个结构通过上一节可以发现其中存放的是无符号类型的地址和端口, 还是网络字节顺序, 而现实中经常使用的是使用的是域名或者IP地址, 因此系统提供了方便快捷的 getaddrinfo 函数, 用于快速转换.

#include <sys/types.h>
#include <sys/socket.h>
@include <netdb.h>

int getaddrinfo(const char *hostname, const char *service, const struct addrinfo *hints, struct addrinfo **result);

void freeaddrinfo(struct addrinfo *result);         //释放result指向的指针

const char *gai_strerror(int errcode);  //辅助函数,用于打印getaddrinfo的错误信息

这个函数如果成功返回0, 不成功返回错误代码. 其参数如下:

  1. hostname, 是一个字符串, 是主机名称(域名或者host文件中的对应名称)或者IP地址.
  2. service, 是一个字符串, 可以是端口号, 也可以是知名服务名称, 比如ftp, http. 一般使用端口号, 和 hostname 参数一起组成套接字地址.
  3. hints, 顾名思义, 这其实是一个提示, 是一个指向addrinfo结构的指针, 调用者需要在指针指向的结构里填上一些必要数据, 来指示这个函数的工作. 详情要和最后一个参数结合起来看.
  4. result, 指向addrinfo类型的指针的指针. 这个函数会在result变量中填入一个指针, 指针指向一个指向addrinfo的指针.

要搞清楚这个函数, 首先要完整的看一下这个函数如何使用.

在使用之前, 调用者要必须初始化一个addrinfo 结构, 这个结构只能设置 ai_flags, ai_family, ai_socktype, ai_protocol这四个字段, 其他字段必须为0或者NULL(所以一般先清零). 这个结构如下:

struct addrinfo
{        
	int ai_flags;               //一个位掩码, 用于一些设置, 可以用 OR 运算符连接
	int ai_family;              //AF_INET 或者 AF_INET6
	int ai_socktype;            //详述
	int ai_protocol;            //设置为0
	size_t ai_addrlen;
	struct sockaddr *ai_addr;   //指向套接字地址结构的指针
	char *ai_canonname;
	struct addrinfo *ai_next;   //指向下一个addrinfo的指针, 所以这是一个链表
};

能设置的四个参数含义如下:

  1. ai_flags, 这个是用于控制的位掩码, 有如下设置:
    1. AI_ADDRCONFIG, 当本地主机被配置为IPV4的时候, 就返回IPV4地址, 配置为IPV6就返回IPV6地址. 如果要使用 connect 函数, 就设置这个.
    2. AI_CANONNAME, 不设置该标记则 char *ai_canonname 默认为 NULL, 设置了就会将链表中第一个addrinfo结构的ai_canonname指向host的名称.
    3. AI_NUMBERICSERV, 设置该标记强制指定getaddrinfo的第二个参数service为端口号的字符串.
    4. AI_PASSIVE, getaddrinfo默认返回主动套接字, 设置这个标记可以返回被动套接字用于服务端, 设置这个标记同时还约束了 getaddrinfo 的第一个参数hostname必须为NULL. 这样返回的套接字地址内是通配符.
  2. ai_socktype, 这个可以设置成TCP连接(SOCK_STREAM, 此时result 对于每个地址只有一个只有头节点的链表对应, 有几个地址就有几个节点. 默认是最多返回3个节点的链表.), 还可以设置成数据报或者原始套接字.
  3. protocol, 这个就是协议号, 设置成0即可. 一般清零之后就无需特意设置了.

通过参数可以发现, 对于客户端和服务端, 调用 getaddrinfo 所需的准备工作也不同, 需要采用不同的方式填充 addrinfo, 然后调用 getaddrinfo:

  1. 客户端调用, 则必须知道 hostname 和 service. addrinfo 结构中的 ai_flags 可以不设置. ai_family 则是AF_INET(IPV6就是AF_INET6), ai_protocol是0.
  2. 服务端调用, hostname设置为NULL, 表示接受所有的地址访问. service必须设置成指定的端口. ai_flags设置成AI_PASSIVE.

调用之后 getaddrinfo 根据服务端还是客户端会进行不同的工作:

  1. 客户端, 会依次尝试在每个套接字地址上调用 socket 和 connect, 直到成功.
  2. 服务端, 会尝试在套接字地址上调用 socket 和bind ,直到成功.

此时返回值result中就放置了 addrinfo 链表, 其中的 ai_addr 字段就对应上述成功调用之后的套接字地址数据结构. 从其中取出需要的数值, 然后继续调用 socket 系列函数即可.

可以导致返回多个addrinfo结构的情形有以下2个:

  1. 如果与hostname参数关联的地址有多个,那么适用于所请求地址簇的每个地址都返回一个对应的结构。
  2. 如果service参数指定的服务支持多个套接口类型,那么每个套接口类型都可能返回一个对应的结构,具体取决于hints结构的ai_socktype成员

可见在一般情况下, 不会返回多个结构, 所以只要用指针指向的第一个结构中的数据进行调用后续的socket函数即可.

在实际的使用中, 是先调用 getaddrinfo 函数, 然后从 result 中获取数据, 当成 socket 函数的参数.

在使用完 getaddrinfo 函数其中的数据后, 需要释放这个函数返回的 addrinfo 占用的内存.

如果出错, 就使用 gai_strerror(int errcode) 函数来打印错误信息.

getnameinfo

这个函数与 getaddrinfo 相反, 是先知道一个套接字地址, 然后将其转换成相应的主机和服务名字符串.

#include <sys/socket.h>
#include <netdb.h>

int getnameinfo(const struct sockaddr *sa, socklen_t salen, char *host, size_t hostlen, char *service, size_t servlen, int flags);

依然是成功返回0, 不成功返回-1, 参数如下:

  1. sa, 要转换的套接字地址结构, 这个结构可以由accept函数返回.
  2. salen, 套接字地址的长度, 也可以由accept函数返回.
  3. host, 存放转换出来的主机名称的字符串
  4. hostlen, 主机名字符串长度
  5. service, 存放转换后端口号字符串
  6. servlen, 字符串长度
  7. flags, 控制掩码. 设置为NI_NUMBERICHOST表示返回一个数字地址字符串, 而不是默认的域名. NI_NUMERICSERV会返回端口号, 如果不设置, 默认是根据/etc/services返回服务名.

如果不想要host或者service, 可以传入NULL指针和0长度. 但不能两个同时都不要.

试着编写一个程序来用一下这两个函数:

#include <stdio.h>
#include <sys/socket.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAXLINE 8192


int main(int argc, char **argv) {
    //声明所需的一批变量
    struct addrinfo *p, *listp, hints;
    char buf[MAXLINE];
    int rc, flags;

    //判断命令行是否正确
    if (argc != 2) {
        fprintf(stderr, "usage: %s <domain name>\n", argv[0]);
        exit(0);
    }

    //清空hints结构
    memset(&hints, 0, sizeof(struct addrinfo));

    //设置hints的参数, 由于这里要把域名换成地址, 所以是主动socket, 要去连接指定的地址
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;

    //判断 getaddrinfo 是否出错
    if ((rc = getaddrinfo(argv[1], NULL, &hints, &listp)) != 0) {
        fprintf(stderr, "GETADDRINFO ERROR: %s\n", gai_strerror(rc));
        exit(1);
    }

    //设置调用getnameinfo的flags, 返回IP地址而不是域名
    flags = NI_NUMERICHOST;

    //遍历listp结构, 对于其中的每个节点的 ai_addr, 转换成域名放在buf里, 然后打印出来
    for (p = listp; p; p = p->ai_next) {
        getnameinfo(p->ai_addr, p->ai_addrlen, buf, MAXLINE, NULL, 0, flags);
        printf("%s\n", buf);
    }

    //释放完无需再使用的listp
    freeaddrinfo(listp);

    exit(0);
}

域名的转换其实是通过查询本地host及一系列文件, 如果查不到, 是使用UDP去向域名服务器查询, 然后再调用connect去连接, 所以实际上已经连过网了. 具体细节可以看这里

练习11.4 使用inet_pton 而不是 getnameinfo 来转换套接字地址为IP地址字符串

思路如下, 将套接字地址转换为IP地址字符串, 已经知道了使用的函数是const char *inet_ntop(AF_INET, const void *src, char *dst, socklen_t size), 很显然, 核心就是要获取src参数也就是无符号整数表示的地址.

在执行完 getaddrinfo 之后, 实际上每个 addrinfo 中 的 ai_addr 是一个指向 sockaddr 类型的指针. 需要将其转换成 sockaddr_in 类型的指针, 然后根据sockaddr_in 结构, 从其中取出 sin_addr 就是网络地址 in_addr 类型. 其中就是无符号32位整数, 可以再取一层, 也可以直接将其指针传入inet_ntop使用.

#include <stdio.h>
#include <sys/socket.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAXLINE 8192

int main(int argc, char **argv) {
    struct addrinfo *p, *listp, hints;
    //定义从 addrinfo 中获取 ai_addr 指针的变量
    struct sockaddr * middleaddr;
    //定义将 sockaddr 结构转换成 sockaddr_in 结构的指针
    struct sockaddr_in *targetaddr;
    char buf[MAXLINE];
    int rc;
    //定义无符号地址整数的变量
    unsigned int addr;


    if (argc != 2) {
        fprintf(stderr, "usage: %s <domain name>\n", argv[0]);
    }

    memset(&hints, 0, sizeof(struct addrinfo));

    hints.ai_family = AF_INET;

    hints.ai_socktype = SOCK_STREAM;

    if ((rc = getaddrinfo(argv[1], NULL, &hints, &listp)) != 0) {
        fprintf(stderr, "GETADDRINFO ERROR: %s\n", gai_strerror(rc));
        exit(1);
    }

    //此时获取了每个节点里的 ai_addr 就是 sockaddr 结构, 将其转换为 sockaddr_in 之后, 其中的 in_addr 结构就是IP地址结构, 其中的 s_addr 就是IP地址的无符号整数了

    for (p = listp; p; p = p->ai_next) {

        // middleaddr 是指向 sockaddr 结构的指针
        middleaddr = (p->ai_addr);

        //强制类型转换成 sockaddr_in 类型的指针
        targetaddr = (struct sockaddr_in *) middleaddr;

        //获取无符号32位整数的地址表示, 注意, 已经是经过了网络字节顺序变化的了, 因为是 getaddrinfo 设置的
        addr = targetaddr->sin_addr.s_addr;

        //转换成字符串写入buf中
        inet_ntop(AF_INET, &addr, buf, 16);

        printf("%s\n", buf);
    }
    freeaddrinfo(listp);
    exit(0);
}

一开始自己编写的时候, 想把 sockaddr 结构直接转换成 sokcaddr_in 结构, C语言提示 error: conversion to non-scalar type requested, 这是因为标量以外的类型, 是不能直接转换类型的. 想要以不同的方式操作, 需要采取转换指针类型的方式, 而不是转换结构本身.

在有了 getaddrinfo 之后, 调用函数的流程就变成了:

顺序 客户端 服务端
1 将想连接的地址和端口送入 getaddrinfo 函数, 获取addrinfo的数据结构 同样调用 getaddrinfo
2 从addrinfo 中取出数据, 执行 socket 函数获取描述符 和客户端一样
3 把 addrinfo->ai_addr, addrinfo->ai_addrlen 传入到 connect 函数中创建连接 把 addrinfo->ai_addr, addrinfo->ai_addrlen 传入到 bind 函数中绑定端口
4 关闭 addrinfo 的指针 调用 listen
5 调用 accept, 如果有需要可以获取客户端的套接字地址.
6 关闭 addrinfo 的指针

可以看到其核心是获取描述符, 如果能用一个函数把这些过程都封装好, 直接返回一个可以读写的套接字描述符就好了. 由于流程很清晰, 所以可以自己封装一个open_clientfd 和 open_listenfd 函数, 直接返回可供读写的描述符.

open_clientfd

这个函数用于客户端, 其核心思想就是根据域名和端口, 返回一个连接成功的套接字描述符. 其内部无非就是上边这些过程的集合, 编写如下:

#include <stdio.h>
#include <sys/socket.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int open_clientfd2(char *hostname, char *port){

    //要返回的套接字描述符
    int clientfd;
    //是否成功解析了地址
    int is_getaddrinfo;
    //调用 getaddrinfo 所需要准备的变量
    struct addrinfo hints, *listp, *p;

    //调用 getaddrinfo , 获取成功的链表
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_family = AF_INET;
    //设置FLAG为要使用connect , 以及强制使用端口号作为参数
    hints.ai_flags = AI_ADDRCONFIG|AI_NUMERICSERV;
    //这里需要判断一下是否能够解析
    if ((is_getaddrinfo = getaddrinfo(hostname, port, &hints, &listp)) != 0) {
        printf("解析地址错误: %s\n", gai_strerror(is_getaddrinfo));
        exit(0);
    }

    //遍历链表
    for (p = listp; p; p = p->ai_next) {
        //尝试调用socket返回描述符
        if ((clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) {
            //失败就继续下一个
            continue;
        }

        //连接成功就直接退出循环
        if ((connect(clientfd, p->ai_addr, p->ai_addrlen)) != -1) {
            break;
        }

        //执行到这里说明连接失败, 要关闭这个描述符. 下一次循环中再尝试打开
        close(clientfd);
    }

    //执行到这里已经获取了clientfd或者遍历完了链表, 可以释放链表
    Freeaddrinfo(listp);

    //如果p为空, 则说明遍历了所有节点都连接失败, 就返回-1. 如果p不为空说明在p处连接成功并break出来, 就返回clientfd描述符
    if(!p) {
        return -1;
    } else {
        return clientfd;
    }
}

int main(int argc, char **argv){

    int fd = open_clientfd2(argv[1], argv[2]);

    printf("获得的套接字是 %d \n", fd);
}

通过这个函数, 就可以传入域名和端口直接获取可以进行读写的套接字了. 这个函数我是自行编写自CSAPP的例子, 加上了判断getaddrinfo的结果, 避免写入错误的链表内容. 经过试验可以成功封装.

open_listenfd

作为服务器的封装版本, 需要调整一下hints.ai_flags的细节, 在调用 getaddrinfo 的时候, 注意使用的参数. 调用完之后, 由于无需遍历节点, 依次调用 socket, bind ,listen 即可.

#include <stdio.h>
#include <sys/socket.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

//创建服务端, 只需要端口, 无需地址
int open_serverfd2(char *port){

    //要返回的监听套接字描述符
    int listenfd;
    //是否成功解析了地址
    int is_getaddrinfo;
    //调用 getaddrinfo 所需要准备的变量
    struct addrinfo hints, *listp, *p;
    //重用套接字的设置
    int reuse = 1;

    //调用 getaddrinfo , 获取成功的链表
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_family = AF_INET;
    //注意AI_PASSIVE的设置
    hints.ai_flags = AI_ADDRCONFIG|AI_PASSIVE|AI_NUMERICSERV;
    //使用NULL来调用
    if ((is_getaddrinfo = getaddrinfo(NULL, port, &hints, &listp)) != 0) {
        printf("getaddrinfo函数出错: %s\n", gai_strerror(is_getaddrinfo));
        exit(0);
    }

    //遍历链表, 找到可以bind的地址
    for (p = listp; p; p = p->ai_next) {
        //调用 socket 函数获取描述符
        if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) {
            //失败就继续下一个
            continue;
        }

        //由于每次循环间隔很短, 这里要对套接字描述符进行设置, 让其在关闭之后可以迅速被重用
        Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (const void *)&reuse, sizeof(int));


        //尝试bind, 成功绑定就跳出循环
        if ((bind(listenfd, p->ai_addr, p->ai_addrlen)) == 0) {
            break;
        }

        //执行到这里说明无法绑定, 关闭这个描述符. 下一次循环会再设置描述符
        close(listenfd);
    }

    //执行到这里已经获取了绑定成功的监听套接字或者遍历完链表, 可以释放链表
    Freeaddrinfo(listp);

    //如果p为空, 则说明遍历了所有节点都连接失败, 就返回-1. 如果p不为空说明绑定成功, 继续调用listen函数
    if(!p) {
        return -1;
    }

    //listen函数
    if (listen(listenfd, 1024) == -1) {
        Close(listenfd);
        return -1;
    }
    return listenfd;
}

int main(int argc, char **argv){

    int fd = open_serverfd2(argv[1]);

    printf("获得的监听套接字是 %d \n", fd);
}

至此就基本上了解了系统的socket接口函数, 再深的内容估计就要看UNIX网络编程了.

这里新使用的函数是Setsockopt, 是 setsockopt 函数的CSAPP套壳版. 这个是固定用法, 记住即可.

之后来尝试写Web服务器吧.