Linux Socket 编程进阶
一、Socket 属性
套接字的特性由3个属性确定,它们是:域 (domain)、类型(type)和协议 (protocol)。套接字还用地址作为它的名字。地址的格式随域(又被称为协议族,protocol family)的不同而不同。每个协议族又可以使用一个或多个地址族来定义地址格式。
1、套接字的域
域指定套接字通信中使用的网络介质。最常见的套接字域是 AF_INET,它指的是 Intenet 网络,许多 Linux 局域网使用的都是该网络,当然,因特网自身用的也是它。其底层的协议网际协议(IP)只有一个地址族,它使用一种特定的方式来指定网络中的计算机,即人们常说的IP地址。
“下一代”互联网协议 Ipv6 被设计用于克服标准 IP 带来的一些问题,特别是可用地址数量有限的问题。IPv6 使用一个不同的套接字域 AF_INET6 和一个不同的地址格式。人们期望它能最终替换 IP,但这一过程将需要经过许多年。
虽然我们几乎总是用域名来指定因特网上的联网机器,但它们都会被转换为底层的 IP 地址。例如 192.168.1.99 就是一个IP地址。所有的IP地址都用4个数字来表示,每个数字都小于256,即所谓的点分四元组表示法 (dottedquad)。当客户使用套接字进行跨网络的连接时,它就需要用到服务器计算机的IP地址。
服务器计算机上可能同时有多个服务正在运行。客户可以通过IP端口来指定一台联网机器上的某个特定服务。在系统内部,端口通过分配一个唯一的16位的整数来标识,在系统外部,则需要通过IP地址和端口号的组合来确定。套接字作为通信的终点,它必须在开始通信之前绑定一个端口。
服务器在特定的端口等待客户的连接。知名服务所分配的端口号在所有Limux和UNIX机器上都是样的。它们通常(但并不总是如此)小于1024,比如打印机缓冲队列进程(515)、rlogin (513)、ftp(21)和httpd(80)等。其中最后一个就是 Web 服务器的标准端口。一般情况下,小于1024的端口号都是为系统服务保留的,并且所服务的进程必须具有超级用户权限。X/Open 规范在头文件 netdb.h 中定义了一个常量 IPPORT_RESERVED,它代表保留端口号的最大值。
因为标准服务都对应标准的端口号,所以计算机之间可以轻松地互连,而不需要首先协商一个正确的端口号。本地服务可以使用非标准的端口地址。
第一个例子中的域是 UNIX 文件系统域 AF_UNIX 即使是一台还未联网的计算机上的套接字也可以使用这个域。这个域的底层协议就是文件输入/输出,而它的地址就是文件名。我们的服务器套接字的地址是server_socket,当我们运行服务器程序时,就可以在当前目录下看到这个地址。其他可以使用的域还包括:基于 ISO 标准协议的网络所使用的 AF_ISO 域和用于施乐(Xerox)网络系统的 AF_XNS 域。
2、套接字类型
一个套接字域可能有多种不同的通信方式,而每种通信方式又有其不同的特性。但 APUNIX 域的套接字没有这样的问题,它们提供了一个可靠的双向通信路径。在网络域中,我们就需要注意底层网络的特性,以及不同的通信机制是如何受到它们的影响的。因特网协议提供了两种通信机制:流(stream)和数据报(datagram)。它们有着截然不同的服务层次。
流套接字
流套接字(在某些方面类似于标准的输入/输出流)提供的是一个有序、可靠、双向字节流的连接因此,发送的数据可以确保不会丢失、复制或乱序到达,并且在这一过程中发生的错误也不会显示出来。大的消息将被分片、传输、再重组。这很像一个文件流,它接收大量的数据,然后以小数据块的形式将它们写入底层磁盘。流套接字的行为是可预见的。流套接字由类型SOCKSTREAM指定,它们是在AF_INET域中通过TCP/IP连接实现的。它们也是AF_UNIX域中常用的套接字类型。在本章中,我们将重点学习SOCK_STREAM套接字,因为它们在编写网络程序时是最常用的。
TCP/IP代表的是传输控制协议(Transmission Control Protocol)/网际协议(IntermetProtocol)。IP协议是针对数据包的底层协议,它提供从一台计算机通过网络到达另一台计算机的路由。TCP协议提供排序、流控和重传,以确保大数据的传输可以完整地到达目的地或报告一个适当的错误条件。
数据报套接字
与流套接字相反,由类型SOCK_DGRAM指定的数据报套接字不建立和维持一个连接。它对可以发送的数据报的长度有限制。数据报作为一个单独的网络消息被传输,它可能会丢失、复制或乱序到达。
数据报套接字是在 AF_INET 域中通过 UDP/IP 连接实现的,它提供的是一种无序的不可靠服务(UDP代表的是用户数据报协议)。但从资源的角度来看,相对来说它们开销比较小,因为不需要维持网络连接。而且因为无需花费时间来建立连接,所以它们的速度也很快。
数据报适用于信息服务中的“单次”(single-shot)查询,它主要用来提供日常状态信息或执行低优先级的日志记录。它的优点是服务器的崩溃不会给客户造成不便,也不会要求客户重启,因为基于数据报的服务器通常不保留连接信息,所以它们可以在不打扰其客户的前提下停止并重启。现在,我们暂时离开对数据报的讨论,关于数据报的更多信息请阅读本章最后一节。
3.套接字协议
只要底层的传输机制允许不止一个协议来提供要求的套接字类型,我们就可以为套接字选择一个特定的协议。
二、创建 Socket
socket系统调用创建一个套接字并返回一个描述符,该描述符可以用来访问该套接字。
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
创建的套接字是一条通信线路的一个端点。domain参数指定协议族,type参数指定这个套接字的通信类型,protocol参数指定使用的协议。
domain参数可以指定的协议族如下表所示:
- AF_UNIX UNIX域协议(文件系统套接字)
- AF_INET ARPA因特网协议(UNIX网络套接字)
- AF_ISO ISO标准协议
- AF_NS 施乐(Xerox)网络系统协议
- AF_IPX Novell IPX协议
- AF_APPLETALK Appletalk DDS
最常用的套接字域是 AF_UNIX 和 AF_INET,前者用于通过 UNIX 和 Linux 文件系统实现的本地套接字,后者用于 UNIX 网络套接字。AP_INET 套接字可以用于通过包括因特网在内的 TCP/IP 网络进行通信的程序。微软 Windows 系统的 Winsock 接口也提供了对这个套接字域的访问功能。socket 函数的参数 type 指定用于新套接字的通信特性。它的取值包括 SOCK_STREAM 和 SOCK_DGRAM。
SOCK_STREAM是一个有序、可靠、面向连接的双向字节流。对AF_INET域套接字来说,它默认是通过一个TCP连接来提供这一特性的,TCP连接在两个流套接字端点之间建立。数据可以通过套接字连接进行双向传递。TCP协议所提供的机制可以用于分片和重组长消息,并且可以重传可能在网络中丢失的数据。
SOCK_DGRAM是数据报服务。我们可以用它来发送最大长度固定(通常比较小)的消息,但消息是否会被正确传递或消息是否不会乱序到达并没有保证。对于AP_INET 域套接字来说,这种类型的通信是由 UDP 数据报来提供的。
通信所用的协议一般由套接字类型和套接字域来决定,通常不需要选择。只有当需要选择时,我们才会用到 protocol 参数。将该参数设置为 0 表示使用默认协议。
socket 系统调用返回一个描述符,它在许多方面都类似于底层的文件描述符。当这个套接字连接到另一端的套接字后,我们就可以用 read 和 write 系统调用,通过这个描述符来在套接字上发送和接收数据了。close系统调用用于结束套接字连接。
三、Socket 地址
每个套接字域都有其自己的地址格式。对于AF_UNIX域套接字来说,它的地址由结构sockaddrun来描述,该结构定义在头文件sys/un.h中。
struct sockaddr_un {
sa_family_t sun_family; /* AF UNIX */
char sun path[]; /* pathname */
};
因此,对套接字进行处理的系统调用可能需要接受不同类型的地址,每种地址格式都使用一种类似的结构来描述,它们都以一个指定地址类型(套接字域)的成员(在本例中是sun_family)开始。在AF_UNIX域中,套接字地址由结构中的 sun_path 成员中的文件名所指定。
在当前的 Linux 系统中,由 X/Open 规范定义的类型 sa_family_t 在头文件 sys/un.h 中声明,它是短整数类型。此外,sun_path指定的路径名长度也是有限制的(Linux规定的是108个字符,其他系统可能使用的是更清楚的常量,如 UNIX_MAX_PATH)。因为地址结构的长度不一致,所以许多套接字调用需要用到一个用来复制特定地址结构的长度变量或将它作为一个输出。在 AF_INET 域中,套接字地址由结构 sockaddr_in 来指定该结构定义在头文件 netinet/in.h 中它至少包含以下几个成员:
struct sockaddr_in {
short int sin_family; /* AF_INET */
unsigned short int sin_port; /* Port number */
struct in_addr sin_addr; /* Internet address */
};
IP地址结构inaddr被定义为:
struct in_addr {
unsigned long int s_addr;
};
IP地址中的4个字节组成一个32位的值。一个 AF_INET 套接字由它的域、IP地址和端口号来完全确定。从应用程序的角度来看,所有套接字的行为就像文件描述符一样,并且通过一个唯一的整数值来区分。
四、命名 Socket
要想让通过socket调用创建的套接字可以被其他进程使用,服务器程序就必须给该套接字命名。这样 AF_UNIX 套接字就会关联到一个文件系统的路径名。AF_INET套接字就会关联到一个IP端口号。
#include <sys/socket.h>
int bind(int socket,const struct sockaddr *address, size_t address_len);
bind 系统调用把参数 address 中的地址分配给与文件描述符 socket 关联的未命名套接字。地址结构的长度由参数 address_len 传递。地址的长度和格式取决于地址族。bind 调用需要将一个特定的地址结构指针转换为指向通用地址类型(struct sockaddr *)。
bind 调用在成功时返回0,失败时返回-1并设置 errno 为下面中的一个值。
- EBADF 文件描述符无效
- ENOTSOCK 文件描述符对应的不是一个套接字
- EINVAL 文件描述符对应的是一个已命名的套接字
- EADDRNOTAVAIL 地址不可用
- EADDRINUSE 地址已经绑定了一个套接字
AF_UNIX域套接字还有其他一些错误代码,如下所示。
- EACCESS 因为权限不足,不能创建文件系统中的路径名
- ENOTDIR、ENAMETOOLONG 表明选择的文件名不符合要求
五、创建 Socket 队列
为了能够在套接字上接受进入的连接,服务器程序必须创建一个队列来保存未处理的请求。它用 listen 系统调用来完成这一工作。
#include <sys/socket.h>
int listen(int socket,int backlog);
Linux 系统可能会对队列中可以容纳的未处理连接的最大数目做出限制。为了遵守这个最大值限制,listen 函数将队列长度设置为 backlog 参数的值。在套接字队列中,等待处理的进入连接的个数最多不能超过这个数字。再往后的连接将被拒绝,导致客户的连接请求失败。listen 函数提供的这种机制允许当服务器程序正忙于处理前一个客户请求的时候,将后续的客户连接放入队列等待处理。backlog 参数常用的值是5。
listen 函数在成功时返回0,失败时返回-1。错误代码包括EBADF、EINVAL和ENOTSOCK,其含义与上面 bind 系统调用中说明的一样。
六、接受连接
一旦服务器程序创建并命名了套接字之后,它就可以通过 accept 系统调用来等待客户建立对该套接字的连接。
#include <sys/socket.h>
int accept(int socket,struct sockaddr *address, size_t *address_len);
accept 系统调用只有当有客户程序试图连接到由 socket 参数指定的套接字上时才返回。这里的客户是指,在套接字队列中排在第一个的未处理连接。accept 函数将创建一个新套接字来与该客户进行通信,并且返回新套接字的描述符。新套接字的类型和服务器监听套接字类型是一样的。
套接字必须事先由 bind 调用命名,并且由 listen 调用给它分配一个连接队列。连接客户的地址将被放入 address 参数指向的 sockaddr 结构中。如果我们不关心客户的地址,也可以将 address 参数指定为空指针。
参数 address_len 指定客户结构的长度。如果客户地址的长度超过这个值,它将被截断。所以在调用 accept 之前,address_len 必须被设置为预期的地址长度。当这个调用返回时,address_len将被设置为连接客户地址结构的实际长度。
如果套接字队列中没有未处理的连接,accept 将阻塞(程序将暂停)直到有客户建立连接为止。我们可以通过对套接字文件描述符设置 O_NONBLOCK 标志来改变这一行为,使用的函数是 fcntl,如下所示:
int flags = fcntl(socket,F_GETFL, 0);
fcntl(socket,F_SETFL,O_NONBLOCK|flags);
当有未处理的客户连接时,accept 函数将返回一个新的套接字文件描述符。发生错误时,accept 函数将返回-1。可能的错误情况大部分与 bind、listen 调用类似,其他的错误有 EWOULDBLOCK 和 EINTR。前者是当指定了 O_NONBLOCK 标志,但队列中没有未处理连接时产生的错误。后者是当进程阻塞在 accept 调用时,执行被中断而产生的错误。
七、请求连接
客户程序通过在一个未命名套接字和服务器监听套接字之间建立连接的方法来连接到服务器。它们通过 connect 调用来完成这一工作。
#include <sys/socket.h>
int connect(int socket, congt struct sockaddr *address, size_t address_len);
参数 socket 指定的套接字将连接到参数 address 指定的服务器套接字,address 指向的结构的长度由参数 address_len 指定。参数 socket 指定的套接字必须是通过 socket 调用获得的一个有效的文件描述符。
成功时,connect调用返回0,失败时返回-1。可能的错误代码如下:
- EBADF 传递给socket参数的文件描述符无效
- EALREADY 该套接字上已经有一个正在进行中的连接
- ETIMEDOUT 连接超时
- ECONNREFUSED 连接请求被服务器拒绝
如果连接不能立刻建立,connect 调用将阻塞一段不确定的超时时间。一旦这个超时时间到达,连接将被放弃,connect 调用失败。但如果 connect 调用被一个信号中断,而该信号又得到了处理 connect 调用还是会失败(errno被设置为EINTR),但连接尝试并不会被放弃,而是以异步方式继续建立,程序必须在此后进行检查以查看连接是否成功建立。
与 accept 调用一样,connect 调用的阻塞特性可以通过设置该文件描述符的 O_NONBLOCK 标志来改变。此时,如果连接不能立刻建立,connect 将失败并把 errno 设置为 EINPROGRESS,而连接将以异步方式继续进行。
虽然异步连接难于处理,但我们可以在套接字文件描述符上,用 select 调用来检查套接字是否已处于写就绪状态。
八、关闭 Socket
你可以通过调用 close 函数来终止服务器和客户上的套接字连接,就如同对底层文件描述符进行关闭一样。你应该总是在连接的两端都关闭套接字。对于服务器来说,应该在 read 调用返回0时关闭套接字,但如果套接字是一个面向连接类型的,并且设置了 SOCK_LINGER 选项,close 调用会在该套接字还有未传输数据时阻塞。你将在本章后面的内容中学习到如何设置套接字选项。
九、Socket 通信
这里将尽量使用网络套接字而不是文件系统套接字。文件系统套接字的缺点是,除非程序员使用一个绝对路径名,否则套接字将创建在服务器程序的当前目录下。为了让它更具通用型,你需要将它创建在一个服务器及其客户都认可的可全局访问的目录(如/tmp目录)中。而对网络套接字来说,你只需要选择一个未被使用的端口号即可。
我们的例子将选择端口号9734,这个端口号是在避开标准服务的前提下随意选择的(我们不能使用小于1024的端口号,因为它们都是为系统使用保留的)。其他端口号及通过它们提供的服务通常都列在系统文件/etc/services中。编写基于套接字的应用程序时,请注意总要选择没有列在该配置文件中的端口号。
我们将在局域网中运行我们的客户和服务器,但网络套接字不仅可用于局域网,任何带有因特网连接(即使是一个调制解调器拨号连接)的机器都可以使用网络套接字来彼此通信。甚至可以在一台 UNIX 单机上运行基于网络的程序,因为UNIX计算机通常会配置了一个只包含它自身的回路 loopback)网络。出于演示的目的,我们将使用这个回路网络。回路网络对调试网络应用程序也很有用,因为它排除了任何外部网络问题。回路网络中只包含一台计算机,传统上它被称为localhost,它有一个标准的IP地址127.0.0.1。这就是本地主机。你可以在网络主机文件 /etc/hosts 中找到它的地址,在该文件中还列出了在共享网络中的其他主机的名字和对应的地址。
每个与计算机进行通信的网络都有一个与之关联的硬件接口。一台计算机可能在每个网络中都有一个不同的网络名,当然也就会有几个不同的IP地址。例如,Neil 的机器 tilde 就有3个网络接口,因此也就有3个IP地址。它们被记录在文件 /etc/hosts 中,如下所示:
127.0.0.1 localhost # Loopback
192.168.1.1 tilde.localnet # Local, private Ethernet
158.152.X.X tilde.demon.co.uk # Modem dial-up
第一个就是简单的回路网络,第二个是通过一块以太网卡来访问的局域网,第三个是到一个因特网接入服务提供商的调制解调器连接。你编写的基于套接字的网络程序,可以不做任何修改就能通过任何一个网络接口与服务器进行通信。
实例
客户端
/* Make the necessary includes and set up the variables. */
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
int sockfd;
int len;
struct sockaddr_in address;
int result;
char ch = 'A';
/* Create a socket for the client. */
sockfd = socket(AF_INET, SOCK_STREAM, 0);
/* Name the socket, as agreed with the server. */
address.sin_family = AF_INET;
address.sin_addr.s_addr = inet_addr("127.0.0.1");
address.sin_port = 9734;
len = sizeof(address);
/* Now connect our socket to the server's socket. */
result = connect(sockfd, (struct sockaddr *)&address, len);
if(result == -1) {
perror("oops: client2");
exit(1);
}
/* We can now read/write via sockfd. */
write(sockfd, &ch, 1);
read(sockfd, &ch, 1);
printf("char from server = %c\n", ch);
close(sockfd);
exit(0);
}
服务器端
/* Make the necessary includes and set up the variables. */
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
int server_sockfd, client_sockfd;
int server_len, client_len;
struct sockaddr_in server_address;
struct sockaddr_in client_address;
/* Create an unnamed socket for the server. */
server_sockfd = socket(AF_INET, SOCK_STREAM, 0);
/* Name the socket. */
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = inet_addr("127.0.0.1");
server_address.sin_port = 9734;
server_len = sizeof(server_address);
bind(server_sockfd, (struct sockaddr *)&server_address, server_len);
/* Create a connection queue and wait for clients. */
listen(server_sockfd, 5);
while(1) {
char ch;
printf("server waiting\n");
/* Accept a connection. */
client_len = sizeof(client_address);
client_sockfd = accept(server_sockfd,
(struct sockaddr *)&client_address, &client_len);
/* We can now read/write to client on client_sockfd. */
read(client_sockfd, &ch, 1);
ch++;
write(client_sockfd, &ch, 1);
close(client_sockfd);
}
}
实验解析
服务器程序创建一个AP_INE域的套接字,并安排在它之上接受连接。这个套接字被绑定到你选择的端口。指定的地址决定了允许建立连接的计算机。通过指定像客户程序中一样的回路地址,你就把通信限制在本地主机上。
如果想允许服务器和远程客户进行通信,就必须指定一组你允许连接的IP地址。你可以用特殊值INADDR_ANY来表示,你将接受来自计算机任何网络接口的连接。如果你愿意,还可以通过分离如内部局域网和外部广域网连接的方式来区分不同的网络接口。INADDR ANY是一个32位的整数值,它可以用在地址结构的sin_addr.s_addr域中。但首先你需要解决一个问题。
十、主机字节序和网络字节序
当在基于 Intel 处理器的Linux 机器上运行新版本的服务器和客户程序时,我们可以用 netstat 命令来查看网络连接状况。这个命令在大多数配置了网络功能的 UNIX 系统上都能找到。它显示了客户/服务器连接正在等待关闭。连接将在一小段超时时间之后关闭(具体的输出内容将随 Liux 版本的不同而不同)。
$ ./server2 & ./client2
[3]23770
server waiting
server waiting
char from server = B
$ netstat -A inet
Active Internet connections(w/o servers)
... ...
在尝试运行例程序之前,请确保已终止正在运行的示例服务器程序,因为它们会争夺来自客户的连接,会导致运行结果混乱。你可以用下面的命今来将它们一起杀掉:
kilall gerverl server2 server3 server4 server5
你可以看到这条连接对应的服务器和客户的端口号。local address 一栏显示的是服务器,而 foreign address 一栏显示的是远程客户(即使是在同一台机器上,它仍然是通过网络连接的)。为了确保所有套接字都是不同的,这些客户端口一般都与服务器监听套接字不同,并且在这台计算机上是唯一的。
可是,显示的本地地址(服务器套接字)端口是1574(或者你可能会看到显示的是一个服务名mvel-lm),而我们选择的端口是9734。为什么会不一样呢?答案是,通过套接字接口传递的端口号和地址都是二进制数字。不同的计算机使用不同的字节序来表示整数。例如,Intel 处理器将32位的整数分为4个连续的字节,并以字节序 1-2-3-4 存储到内存中,这里的1表示最高位的字节。而 BMPowerPC 处理器是以字节序 4-3-2-1 的方式来存储整数。如果保存整数的内存只是以逐个字节的方式来复制,两个不同的计算机得到的整数值就会不一致。
为了使不同类型的计算机可以就通过网络传输的多字节整数的值达成一致,你需要定义一个网络字节序。客户和服务器程序必须在传输之前,将它们的内部整数表示方式转换为网络字节序。它们通过定义在头文件 netinet/in.h中的函数来完成这一工作。这些函数如下所示:
#include <netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort);
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);
这些函数将16位和32位整数在主机字节序和标准的网络字节序之间进行转换。函数名是与之对应的转换操作的简写形式。例如“host to network,long”(htonl,长整数从主机字节序到网络字节序的转换)和“host to network,shot”(htons,短整数从主机字节序到网络字节序的转换)。如果计算机本身的主机字节序与网络字节序相同,这些函数的内容实际上就是空操作。
为了保证16位的端口号有正确的字节序,你的服务器和客户需要用这些函数来转换端口地址。只需改动和是:
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
server_address.sin_port = htons(9734);
你不需要对函数调用 inet_addr("127.0.0.1")进行转换,因为 inet_addr 已被定义为产生一个网络字节序的结果。客户程序只需改动是:
address.sin_port = htons(9734);
服务器也做了改动,通过用 INADDR_ANY 来允许到达服务器任一网络接口的连接。运行时,你将看到本地连接使用的是正确的端口。
请记住,如果你使用的计算机上的主机字节序和网络字节序相同,你将不会看到任何差异。但为了让不同体系结构的计算机上的客户和服务器可以正确地操作,总是在网络程序中使用这些转换函数仍然是非常重要的。