0%

Unix套接字接口

简介

套接字是操作系统中用于网络通信的重要结构,它是建立在网络体系结构的传输层,用于主机之间数据的发送和接收,像web中使用的http协议便是建立在socket之上的。这一节主要讨论网络套接字。

套接字接口时一组函数,它们和Unix I/O结合起来,用于创建网络应用。许多操作系统都实现了自己的套接字接口。在Unix中,可以将套接字视为一个文件,使用文件I/O函数对套接字进行操作,这也贯彻了Unix中一切皆文件的思想。

既然是网络通信,那么就需要服务端和客户端,一个基本的客户端和服务端的通信模型如下,其中方框里为使用的函数接口:

由于套接字本质上也是一个文件,所以上图中的recvsend也可以使用文件I/O函数readwrite替代。

套接字地址结构

既然要进行网络通信,那么就要有基本的网络的地址。因特网的套接字地址存放在一个叫sockaddr_in的16字节的结构体中,其中的IP地址和端口号都是以网络字节序(大端序)存放的:

1
2
3
4
5
6
7
8
9
10
11
struct sockaddr_in {
uint16_t sin_family; /* 协议类型,总是AF_INET */
uint16_t sin_port; /* 端口号 */
struct in_addr sin_addr; /* IP地址 */
unsigned char sin_zero[8]; /* sizeof(struct in_addr) */
};

struct sockaddr {
uint16_t sin_family; /* 协议类型 */
char sa_data[14]; /* 地址信息 */
}

注意到,上面还有一个sockaddr结构体类型。由于connectbindaccept都需要接受一个指向与协议相关的套接字地址结构指针。但是在设计这些接口时,如何定义这些接口,使之能够接受各种类型的套接字地址。为了解决这个问题,设计了sockaddr这种通用的结构体,在使用这些接口时,都需要将与协议相关的结构体转化为这个通用的结构体。

创建和关闭套接字

使用socket函数创建一个套接字,函数原型如下:

1
2
3
4
5
#include <sys/types.h>
#include <sys/socket.h>

/* 返回:若成功则为非负描述符,失败返回-1 */
int socket(int domain, int type, int protocol);

参数domain(域)用于确定通讯的特性,具体如下:

描述
AF_INET IPv4因特网域
AF_INET6 IPv6因特网域
AF_UNIX UNIX域
AF_UPSPEC 未指定

参数type确定套接字的类型,进一步确定通讯特性:

类型 描述
SOCK_DGRAM 固定长度的,无连接的,不可靠的报文传递
SOCK_RAW IP协议的数据报接口
SOCK_SEQPACKET 固定长度的,有序的,可靠的,面向连接的报文协议
SOCK_STREAM 有序的,可靠的,双向的,面向连接的字节流(TCP)

参数protocol通常是0,表示使用默认协议,其他选项这里不再赘述。

例如可以像下面这样创建一个套接字:

1
fd = socket(AF_INET, SOCK_STREAM, 0);

其中,AF_INET表示我们使用32位IPv4地址,SOCK_STREAM表示我们使用TCP协议。但是最好的方法使用getaddrinfo函数来自动生成这些函数,这样我们的代码就与具体的协议无关了。我们会在下方进行讲述。

socket函数返回的描述符只是部分打开的,还不能直接使用,取决于是作为客户端还是服务端,需要一些初始化工作。

shutdown可以用来禁止一个套接字的I/O:

1
2
3
4
#include <sys/socket.h>

/* 返回:若成功返回1,若出错返回-1 */
int shutdown(int sockfd, int how);

参数how指明了关闭套接字的方式:

标志 描述
SHUT_RD 关闭读端
SHUT_WR 关闭写端
SHUT_RDWR 关闭读写

尽管使用close函数也够关闭套接字,但是和shutdown函数却有两点不同:当对一个套接字描述符调用close,该套接字的引用计数会减一,只有当引用计数为0时才会真正关闭套接字,而shutdown不管引用计数是否为0,都会关闭套接字。另外,shutdown可以只关闭套接字读写两个方向中的一个方向,保留另一个方向继续工作。

绑定地址

作为服务器,需要给套接字关联一个众所周知的地址,这样别人才能使用这个地址来对服务器进行访问。

使用bind函数来关联地址和套接字:

1
2
3
4
#include <sys/socket.h>

/* 返回:若成功,返回0,出错,返回-1 */
int bind(int sockfd, const struct sockaddr *addr, socklen_t len);

bind函数将告诉内核将addr中的服务器套接字地址和套接字sockfd关联起来。其中lensizeof(sockaddr_in)

对于使用的服务器地址有如下限制:

  • 指定的地址必须有效,不能是其他机器的地址
  • 地址必须和创建套接字时的地址族所支持的格式相匹配
  • 地址中的端口号必须不小于1024,除非该进程有相应特权
  • 一般只能将一个套接字端点绑定到一个地址。

建立监听

作为服务器,为了接受用户的连接,需要监听某个特定的端口。当客户请求连接这个端口时,便创建一个连接。

默认情况下,内核会认为socket函数创建的套接字为主动套接字,它存在于用于连接的客户端。调用listen函数来告诉内核描述符是被服务器所使用的,而不是客户端。

1
2
3
4
#include <sys/socket.h>

/* 返回:成功返回0,失败返回-1 */
int listen(int sockfd, int backlog);

listen函数将一个主动套接字转化为一个监听套接字,该套接字可以接受来自客户端的连接请求。backlog参数指明请求队列中未完成连接的数量。当队列已满,进程将拒绝后续的连接请求。

等待连接

一但服务器调用了listen,所用的套接字就可以用来接受连接请求,使用accept函数来接受客户端的连接请求。

1
2
3
4
#include <sys/socket.h>

/* 返回:若成功,返回非负连接描述符,失败返回-1 */
int accept(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict len);

accept返回的描述符是一个新的已连接的描述符,这个套接字描述符用来和客户端进行通信。这个新的套接字和原始套接字(sockfd)具有相同的套接字类型和地址族,传送给accept的原始套接字并没有关联到这个连接,继续可用并接受其他连接。

accept函数会将客户端的地址信息填充到参数addr指向的缓冲区,参数len为缓冲区的大小。如果不需要这些信息。可以将addrlen设为NULL。

如果没有连接请求,accept会阻塞到一个连接请求的到来。如果sockfd处于非阻塞模式,则返回-1,并将errno设置为EAGAINEWOULDBLOCK

建立连接

对于客户端来说,如果要处理一个面向连接的网络服务(SOCK_STREAMSOCK_SEQPACKET)。在交换数据之前,首先要在客户端和服务端之间建立一个连接。

使用connect函数来建立一个连接:

1
2
3
4
#include <sys/socket.h>

/* 返回:成功返回0,出错误返回-1 */
int connect(int sockfd, const struct sockaddr *addr, socklen_t len);

connect函数会试图与套接字地址为addr的服务器建立一个连接,其中lensizeof(sockaddr_in)connect函数会阻塞,直到完成连接的建立或出错。随后,描述符sockfd便可进行读写了。

发送和接收

可以使用send函数来发送数据:

1
2
3
4
#include <sys/socket.h>

/* 返回:若成功,返回发送的字节数,若出错,返回-1 */
ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);

send函数与用于文件读写的write函数基本一致,只是多了第四个参数flags,一般置为0。

使用recv来接收数据:

1
2
3
4
#include <sys/socket.h>

/* 返回:返回数据的字节长度,若无可用数据或对方已经按序结束,返回0,若出错返回-1 */
ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);

recv函数与read函数基本一致,只是多了第四个参数flags,来管理如何接收数据,一般置为0。

主机和服务的转换

在前面对套接字的创建中,我们采用手动指明协议的方式,但是这样编写并不恰当。Linux提供了一些函数用于主机名和服务名与地址之间的相互映射,使用这些函数可以使我们的程序独立于任何特定版本的IP协议。下面看一下这些函数。

getaddrinfo函数用于将主机名,主机地址,服务名和端口号的字符串表示转化为套接字地址结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <sys/socket.h>
#include <netdb.h>

/* 返回:若成功返回0,若出错返回非零错误码 */
int getaddrinfo(const char *restrict host,
const char *restrict service,
const struct addrinfo *restrict hint,
strcut addrinfo **restrict res);

/* 返回:无 */
void freeaddrinfo(struct addrinfo *ai);

/* 返回:错误消息字符串 */
const char *gai_strerror(int errcode);

host参数指定主机地址,可以是域名或者点分十进制的IP地址。service参数可以是服务名也可以是十进制的端口号。主机名和服务名可以两者都提供,也可以只提供一个,但另一个必须是一个空指针。

getaddrinfo函数返回一个链表结构addrinfores指向该结构的第一个节点。使用freeaddrinfo函数可以释放该结构。addrinfo结构的定义至少包含以下成员:

1
2
3
4
5
6
7
8
9
10
11
struct addrinfo {
int ai_flags; /* customize behavior */
int ai_family; /* address family,first arg to socket function */
int ai_socktype; /* socket type,second arg to socket function */
int ai_protocol; /* protocol,third arg to socket function */
char *ai_canonname; /* canonical name for hostname */
socklen_t ai_addrlen; /* length in byte of address */
struct sockaddr *ai_addr; /* address */
struct addrinfo *ai_next; /* next in list */
...
};

getaddrinfo可以指定一个可选的hint来过滤符合条件的地址,包括ai_familyai_flagsai_protocolai_socktype字段。剩余的字段必须置为0或空指针。

下表总结了ai_flags字段的标志,这些标志可以通过or运算指定多个:

标志 描述
AI_ADDRCONFIG 查询配置的地址类型(IPv4或IPv6)。使用连接时推荐。只有当本地主机被配置为IPv4时,getaddrinfo返回IPv4地址。对IPv6同样
AI_ALL 查找IPv4和IPv6地址(仅用于AI_V4MAPPED)
AI_CANONNAME 需要一个规范的名字(与别名相对)
AI_NUMERICHOST 以数字格式指定主机地址
AI_NUMERICSERV 将服务指定为数字端口号
AI_PASSIVE 套接字地址用于监听绑定
AI_V4MAPPED 如果没有找到IPv6地址,返回映射到IPv6格式的IPv4地址

如果getaddrinfo出错,可以将返回的错误码传入gai_strerror函数获取具体的错误信息。

接下来的getnameinfo函数与getaddrinfo函数功能相反,用于将一个地址转化为一个主机名和一个服务名:

1
2
3
4
5
6
7
8
#include <sys/socket.h>
#include <netdb.h>

/* 返回:若成功返回0,出错返回非0值 */
int getnameinfo(const struct sockaddr *restrict addr, socklen_t alen,
char *restrict host, socklen_t hostlen,
char *restrict service, socklen_t servlen,
int flags);

getnameinfo将字节长度为alen的套接字地址addr翻译成一个主机名和一个服务名,如果host非空,则指向一个字节长度为hostlen的缓冲区用于存放主机名。同样的,如果service非空,则指向一个长度为servlen的缓冲区来存放服务名。flags参数指定了函数工作的方式,如下:

标志 描述
NI_DGRAM 服务基于数据报而非基于流
NI_NAMEREQD 如果找不到主机名,将其作为一个错误对待
NI_NOFQDN 对于本地主机,仅返回全限定域名的节点名部分
NI_NUMERICHOST 函数默认返回host中的域名,该标志指定返回主机的数字形式,而非主机名
NI_NUMERICSCOPE 对于IPv6,返回范围ID的数字形式,而非名字
NI_NUMERICSERV 函数默认检查/etc/services,如果可能会返回服务名而不是端口号。该标志指定跳过检查,返回服务地址的数字形式(端口号),而非名字

下面一个来自书中的例子展示域名到它IP地址的映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/* main.c */
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAXLEN 8192

int main(int argc, char **argv) {
struct addrinfo *p, *listp, hints;
char buf[MAXLEN], buf1[MAXLEN];
int rc, flags;

if(argc != 2) {
fprintf(stderr, "argv error\n");
exit(-1);
}

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);
}

flags = NI_NUMERICHOST;
for(p = listp; p; p = p->ai_next) {
getnameinfo(p->ai_addr, p->ai_addrlen, buf, MAXLEN, buf1, MAXLEN, flags);
printf("%s %s\n", buf, buf1);
}

freeaddrinfo(listp);
exit(0);
}

使用如下:

1
2
3
4
$ gcc main.c -o main
$ ./main baidu.com
39.156.69.79 0
220.181.38.148 0

小结

unix提供了一组函数用于创建套接字并完成数据传输:

  • 使用getaddrinfo函数和socket函数来创建套接字,可以使程序更具有通用性。
  • 服务端使用bind给套接字绑定地址,使用listen指定套接字用于监听,并使用accept接受连接
  • 客户端使用connect来连接到服务器
  • 客户端和服务端都可以使用sendrecv来传递数据
  • 使用closeshutdown函数来关闭套接字

总结自《深入理解计算机系统》《Unix环境高级编程》