典型情况中,以套接字为基础的数据通信一端是一个 服务器,另一端是一个客户端。
socket
这一个函数在客户端和服务器都要使用:socket(2)。它是这样被声明的:
int socket(int domain, int type, int protocol);
返回值的类型与open
的相同,一个整数。 FreeBSD从和文件句柄相同的池中分配它的值。这就是允许套接字被以对文件相同的方式处理的原因。
参数domain
告诉系统你需要使用什么 协议族。有许多种协议族存在,有些是某些厂商专有的,其它的都非常通用。协议族的声明在 sys/socket.h中
使用PF_INET
是对于 UDP, TCP 和其它网间协议(IPv4)的情况。
对于参数type
有五个定义好的值,也在 sys/socket.h中。这些值都以 “SOCK_
”开头。 其中最通用的是SOCK_STREAM
, 它告诉系统你正需要一个可靠的流传送服务 (和PF_INET
一起使用时是指 TCP)。
如果指定SOCK_DGRAM
, 你是在请求无连接报文传送服务 (在我们的情形中是UDP)。
如何你需要处理基层协议 (例如IP),或者甚至是网络接口 (例如,以太网),你就需要指定 SOCK_RAW
。
最后,参数protocol
取决于前两个参数,并非总是有意义。在以上情形中,使用取值0
。
未连接的套接字: 对于函数
socket
我们还没有指定我们要连往什么其它(主机)系统。 我们新建的套接字还是未连接的。这是有意的:拿电话类比,我们刚把调制解调器接在电话线上。我们既没有告诉调制解调器发起一个呼叫,也不会应答电话振铃。
sockaddr
各种各样的套接字函数需要指定地址,那是一小块内存空间 (用C语言术语是指向一小块内存空间的指针)。在 sys/socket.h中有各种各样如 struct sockaddr
的声明。 这个结构是这样被声明的:
/* * 内核用来存储大多数种类地址的结构 */ struct sockaddr { unsigned char sa_len; /* 总长度 */ sa_family_t sa_family; /* 地址族 */ char sa_data[14]; /* 地址值,实际可能更长 */ }; #define SOCK_MAXADDRLEN 255 /* 可能的最长的地址长度 */
注意对于sa_data
域的定义有些 不确定性。 那只是被定义为14
字节的数组, 注释暗示内容可能超过14
字节
这种不确定性是经过深思熟虑的。套接字是个非常强大的接口。多数人可能认为比Internet接口强不到哪里 ──大多数应用现在很可能都用它 ──套接字可被用于几乎任何种类的进程间通信, Internet(更精确的说是IP)只是其中的一种。
sys/socket.h提到的各种类型的协议 将被按照地址族对待,并把它们就列在 sockaddr
定义的前面:
/* * 地址族 */ #define AF_UNSPEC 0 /* 未指定 */ #define AF_LOCAL 1 /* 本机 (管道,portal) */ #define AF_UNIX AF_LOCAL /* 为了向前兼容 */ #define AF_INET 2 /* 网间协议: UDP, TCP, 等等 */ #define AF_IMPLINK 3 /* arpanet imp 地址 */ #define AF_PUP 4 /* pup 协议: 例如BSP */ #define AF_CHAOS 5 /* MIT CHAOS 协议 */ #define AF_NS 6 /* 施乐(XEROX) NS 协议 */ #define AF_ISO 7 /* ISO 协议 */ #define AF_OSI AF_ISO #define AF_ECMA 8 /* 欧洲计算机制造商协会 */ #define AF_DATAKIT 9 /* datakit 协议 */ #define AF_CCITT 10 /* CCITT 协议, X.25 等 */ #define AF_SNA 11 /* IBM SNA */ #define AF_DECnet 12 /* DECnet */ #define AF_DLI 13 /* DEC 直接数据链路接口 */ #define AF_LAT 14 /* LAT */ #define AF_HYLINK 15 /* NSC Hyperchannel */ #define AF_APPLETALK 16 /* Apple Talk */ #define AF_ROUTE 17 /* 内部路由协议 */ #define AF_LINK 18 /* 协路层接口 */ #define pseudo_AF_XTP 19 /* eXpress Transfer Protocol (no AF) */ #define AF_COIP 20 /* 面向连接的IP, 又名 ST II */ #define AF_CNT 21 /* Computer Network Technology */ #define pseudo_AF_RTIP 22 /* 用于识别RTIP包 */ #define AF_IPX 23 /* Novell 网间协议 */ #define AF_SIP 24 /* Simple 网间协议 */ #define pseudo_AF_PIP 25 /* 用于识别PIP包 */ #define AF_ISDN 26 /* 综合业务数字网(Integrated Services Digital Network) */ #define AF_E164 AF_ISDN /* CCITT E.164 推荐 */ #define pseudo_AF_KEY 27 /* 内部密钥管理功能 */ #define AF_INET6 28 /* IPv6 */ #define AF_NATM 29 /* 本征ATM访问 */ #define AF_ATM 30 /* ATM */ #define pseudo_AF_HDRCMPLT 31 /* 由BPF使用,就不必在接口输出例程 * 中重写头文件了 */ #define AF_NETGRAPH 32 /* Netgraph 套接字 */ #define AF_SLOW 33 /* 802.3ad 慢速协议 */ #define AF_SCLUSTER 34 /* Sitara 集群协议 */ #define AF_ARP 35 #define AF_BLUETOOTH 36 /* 蓝牙套接字 */ #define AF_MAX 37
用于指定IP的是 AF_INET
。这个符号对应着常量 2
。
在sockaddr
中的域 sa_family
指定地址族, 从而决定预先只确定下大致字节数的 sa_data
的实际大小。
特别是当地址族 是AF_INET
时,我们可以使用 struct sockaddr_in
,这可在 netinet/in.h中找到,任何需要 sockaddr
的地方都以此作为实际替代。
/* * 套接字地址,Internet风格 */ struct sockaddr_in { uint8_t sin_len; sa_family_t sin_family; in_port_t sin_port; struct in_addr sin_addr; char sin_zero[8]; };
我们可这样描绘它的结构:
三个重要的域是: sin_family
,结构体的字节1; sin_port
,16位值,在字节2和3; sin_addr
,一个32位整数,表示 IP地址,存储在字节4-7。
现在,让我们尝试填满它。让我们假设我们正在写一个 daytime协议的客户端,这个协议只是简单的规定服务器写出一个代表当前日期和时间文本字符串到端口13。 我们需要使用 TCP/IP,所以我们需要指定在地址族域指定 AF_INET
。 AF_INET
被定义为 2
。让我们使用 IP地址192.43.244.18,这指向 美国联邦政府(time.nist.gov)的服务器。
顺便说一下,域sin_addr
被声明为类型 struct in_addr
,这个类型定义在 netinet/in.h之中:
/* * Internet 地址 (由历史原因而形成的结构) */ struct in_addr { in_addr_t s_addr; };
而in_addr_t
是一个32位整数。
192.43.244.18 只是为了表示32位整数的方便写法,按每个八位字节列出, 以最高位的字节开始。
到目前为止,我已经看见了sockaddr
。我们的计算机并不将短
整数存储为一个16位实体,而是一个2字节序列。同样的,计算机将32位整数存储为4字节序列。
想象我们这样写程序:
sa.sin_family = AF_INET; sa.sin_port = 13; sa.sin_addr.s_addr = (((((192 << 8) | 43) << 8) | 244) << 8) | 18;
结果会是什么样的呢?
好,那当然是要依赖于其它因素的。在Pentium®或其它x86 为基础的计算机上,它会像这样:
在另一个不同的系统上,它可能会是:
在一台PDP计算机上,它可能又是另一个样子。 不过上面两种情况是今天最常用的了。
译者注: PDP的字节顺序在英语中称为middle-endian或mixed-endian。例如,原数0x44332211会被PDP存储为0x33441122。 VAX也采用这种字节顺序。
通常,要书写可移植的代码,程序员假设不存在那些差异。他们回避这种差异(除了他们使用汇编语言写代码的时候)。唉,可你不能在为套接字写代码时那样轻易的回避这种差异。
为什么?
因为当与另一台计算机通信时, 你通常不知道对方存储数据时是先存放最高位字节 (MSB)还是最低位字节 (LSB)。
你可能会有问题,“那么,套接字可以为我把握这种差异吗?”
它不能。
这个回答可能先是让你感到惊讶, 请记住通用的套接字接口只明白结构体sockaddr
中的域sa_len
和sa_family
。 你不必担心那里的字节顺序(当然, 在FreeBSD上sa_family
只有一个字节, 但是许多其它的 UNIX® 系统没有 sa_len
并使用2字节给 sa_family
,而且数据使用何种顺序都取决于计算机(译者注:此处英文原文的用词为“is native to”))。
其余的数据,也就只剩下sa_data[14]
。 依照地址族,套接字只是将那些数据转发到目的地。
事实上,我们输入一个端口号, 是为了让其它计算机知道我们需要什么服务。并且,当我们提供服务时, 只有读取了端口号我们才知道其它计算机期望从我们这里获得什么服务。另一方面,套接字只将端口号作为数据转发,完全不去理会(译者注:此处英文原文用词为“interpret”)其中的内容。
同样的,我们输入IP地址,告诉途经的每台计算机要将我们的数据发送到哪里。 套接字依然只将其按数据转发。
那就是为什么我们(指程序员,而不是套接字)不得不把使用在我们的计算机上的字节顺序和发送给其它计算机时使用的传统字节顺序区分开来。
我们将把我们的计算机上使用的字节顺序称为 主机字节顺序, 或者就是主机顺序.
有一个在IP发送多字节数据的传统: 最高位字节(MSB)优先。 这,我们将用网络字节顺序提及, 或者简单的称为网络顺序。
现在,如果我们在Intel计算机上编译上面的代码, 我们的主机字节顺序将产生:
但是网络字节顺序 要求我们先存储数据的最高位字节(MSB):
不幸的是,我们的主机顺序 恰恰与网络顺序相反。
我们有几种方法解决这个问题。一种是在我们的代码中 倒置数值:
sa.sin_family = AF_INET; sa.sin_port = 13 << 8; sa.sin_addr.s_addr = (((((18 << 8) | 244) << 8) | 43) << 8) | 192;
这将欺骗我们的编译器把数据按网络字节顺序存储。在一些情形中,这的确是个有效的办法 (例如,用汇编语言编程)。然而,在多数情形中,这会导致一个问题。
想象一下,你用C语言写了一个套接字程序。 你知道它将运行在一台Pentium计算机上, 于是你倒着输入你的所有常量,并且把它们强置为 网络字节顺序。 它工作正常。
然而,有一台,你所信任的旧 Pentium 变成一台生了锈的旧 Pentium。你把它更换为一个 主机顺序与 网络顺序相同的系统。 你需要重新编译你的所有软件。你的所有软件中除了你写的那个程序,都继续工作正常。
你早已经忘记你将全部常量强置为与 主机顺序相反。你花费宝贵时间拽头发,呼唤你曾经听到过的(有些是你编造的)所有上帝的名字, 用击球棍敲打你的显示器,还上演所有其它的传统仪式 试图找到一个原本好端端的程序突然完成不能工作的原因。
最终,你找到了原因,发了一通誓言, 开始重写你的代码。
幸运的是,你不是第一个面对这个问题的人。 其它人已经创建 htons(3) 和 htonl(3) C 语言函数分别将 short
and long
从主机字节顺序转换为 网络字节顺序, 并且还有 ntohs(3) 和 ntohl(3) C 语言函数进行着另外的转换。
在最高位字节(MSB)-最前 的系统上,这些函数什么都不做。在 最低位字节(LSB)-最前的系统上 它们将值转换为正确的顺序。
这样一来,无论你的软件在什么系统上编译, 如果你使用这些函数,你的数据最终都将是正确的顺序。
典型情况中,客户端初始化到服务器的连接。 客户端知道要呼叫哪台服务器:它知道服务器的IP地址,并且知道服务器驻守的 端口。这就好比你拿起电话拨号码 (地址),然后,有人应答,呼叫负责狂欢的人 (端口)。
connect
一旦一个客户端已经建立了一个套接字,就需要把它连接到一个远方系统的一个端口上。这使用 connect(2):
int connect(int s, const struct sockaddr *name, socklen_t namelen);
参数 s
是套接字, 那是由函数socket
返回的值。 name
是一个指向 sockaddr
的指针,这个结构体我们已经展开讨论过了。 最后,namelen
通知系统 在我们的sockaddr
结构体中有多少字节。
如果 connect
成功, 返回 0
。否则返回 -1
并将错误码存放于 errno
之中。
有许多种connect
可能失败的原因。例如,试图发起一个Internet连接时, IP 地址可能不存在,或可能停机, 或者就是太忙,或者可能没有在指定端口上有服务器监听。或者直接拒绝任何特定代码的请求。
现在我们知道足够多去写一个非常简单的客户端, 一个从192.43.244.18获取当前时间并打印到 stdout的程序。
/* * daytime.c * * G. Adam Stanislav 编程 */ #include <stdio.h> #include <string.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> int main() { register int s; register int bytes; struct sockaddr_in sa; char buffer[BUFSIZ+1]; if ((s = socket(PF_INET, SOCK_STREAM, 0)) < 0) { perror("socket"); return 1; } bzero(&sa, sizeof sa); sa.sin_family = AF_INET; sa.sin_port = htons(13); sa.sin_addr.s_addr = htonl((((((192 << 8) | 43) << 8) | 244) << 8) | 18); if (connect(s, (struct sockaddr *)&sa, sizeof sa) < 0) { perror("connect"); close(s); return 2; } while ((bytes = read(s, buffer, BUFSIZ)) > 0) write(1, buffer, bytes); close(s); return 0; }
继续,把它输入到你的编辑器中,保存为 daytime.c,然后编译并运行:
% cc -O3 -o daytime daytime.c % ./daytime 52079 01-06-19 02:29:25 50 0 1 543.9 UTC(NIST) * %
在这一情形中,日期是2001年6月19日,时间是 02:29:25 UTC。你的结果会很自然的变化。
典型的服务器不初始化连接。 相反,服务器等待客户端呼叫并请求服务。服务器不知道客户端什么时候会呼叫, 也不知道有多少客户端会呼叫。服务器就是这样静坐在那儿,耐心等待,一会儿,又一会儿, 它突然发觉自身被从许多客户端来的请求围困,所有的呼叫都同时来到。
套接字接口提供三个基本的函数处理这种情况。
bind
端口像是电话线分机:在你拨一个号码后, 你拨分机到一个特定的人或部门。
有65535个 IP 端口,但是一台服务器通常只处理从其中一个端口进入的请求。这就像告诉电话室操作员我们处于工作状态并在一个特定分机应答电话。 我们使用 bind(2) 告诉套接字我们要服务的端口。
int bind(int s, const struct sockaddr *addr, socklen_t addrlen);
除了在 addr
中指定端口, 服务器还可以包含其自身的 IP 地址。不过,也可以就使用符号常量 INADDR_ANY
,指示服务于无论哪个 IP上的指定端口上的请求。 这个符号和几个相同的常量,声明在 netinet/in.h之中。
#define INADDR_ANY (u_int32_t)0x00000000
想象我们正在为 daytime协议在 TCP/IP的基础上写一个服务器。 回想起使用端口13。我们的sockaddr_in
结构应当像这样:
listen
继续我们的办公室电话类比, 在你告诉电话中心操作员你会在哪个分机后,现在你走进你的办公室,确认你自己的电话已插上并且振铃已被打开。还有,你确认呼叫等待功能开启,这样即使你正在与其它人通话, 也可听见电话振铃。
服务器执守所有经过函数 listen(2) 操作的套接字。
int listen(int s, int backlog);
在这里,变量backlog
告诉套接字在忙于处理上一个请求时还可以接受多少个进入的请求。换句话说,这决定了挂起连接的队列的最大大小。
accept
在你听见电话铃响后,你应答呼叫接起电话。 现在你已经建立起一个与你的客户的连接。这个连接保持到你或你的客户挂线。
服务器通过使用函数 accept(2) 接受连接。
int accept(int s, struct sockaddr *addr, socklen_t *addrlen);
注意,这次 addrlen
是一个指针。这是必要的,因为在此情形中套接字要 填上 addr
,这是一个 sockaddr_in
结构体。
返回值是一个整数。其实, accept
返回一个 新套接字。你将使用这个新套接字与客户通信。
老套接字会发生什么呢?它继续监听更多的请求 (想起我们传给listen
的变量 backlog
了吗?),直到我们 close
(关闭) 它。
现在,新套接字仅对通信有意义,是完全接通的。 我们不能再把它传给 listen
接受更多的连接。
我们的第一个服务器会比我们的第一个客户端复杂一些:我们不仅用到了更多的套接字函数, 还需要把程序写成一个守护程序。
这最好写成:在绑定端口后建立一个子进程。 主进程随后退出,将控制权交回给 shell (或者任何调用主进程的程序)。
子进程调用 listen
,然后启动一个无休止循环。这个循环接受连接,提供服务, 最后关闭连接的套接字。
/* * daytimed - 端口 13 的服务器 * * G. Adam Stanislav 编程 * 2001年6月19日 */ #include <stdio.h> #include <string.h> #include <time.h> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #define BACKLOG 4 int main() { register int s, c; int b; struct sockaddr_in sa; time_t t; struct tm *tm; FILE *client; if ((s = socket(PF_INET, SOCK_STREAM, 0)) < 0) { perror("socket"); return 1; } bzero(&sa, sizeof sa); sa.sin_family = AF_INET; sa.sin_port = htons(13); if (INADDR_ANY) sa.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(s, (struct sockaddr *)&sa, sizeof sa) < 0) { perror("bind"); return 2; } switch (fork()) { case -1: perror("fork"); return 3; break; default: close(s); return 0; break; case 0: break; } listen(s, BACKLOG); for (;;) { b = sizeof sa; if ((c = accept(s, (struct sockaddr *)&sa, &b)) < 0) { perror("daytimed accept"); return 4; } if ((client = fdopen(c, "w")) == NULL) { perror("daytimed fdopen"); return 5; } if ((t = time(NULL)) < 0) { perror("daytimed time"); return 6; } tm = gmtime(&t); fprintf(client, "%.4i-%.2i-%.2iT%.2i:%.2i:%.2iZ\n", tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday, tm->tm_hour, tm->tm_min, tm->tm_sec); fclose(client); } }
我们开始于建立一个套接字。然后我们填好 sockaddr_in
类型的结构体 sa
。注意, INADDR_ANY
的特定使用方法:
if (INADDR_ANY) sa.sin_addr.s_addr = htonl(INADDR_ANY);
这个常量的值是0
。由于我们已经使用 bzero
于整个结构体, 再把成员设为0
将是冗余。 但是如果我们把代码移植到其它一些 INADDR_ANY
可能不是0的系统上, 我们就需要把实际值指定给 sa.sin_addr.s_addr
。多数现在C语言 编译器已足够智能,会注意到 INADDR_ANY
是一个常量。由于它是0,他们将会优化那段代码外的整个条件语句。
在我们成功调用bind
后, 我们已经准备好成为一个 守护进程:我们使用 fork
建立一个子进程。 同在父进程和子进程里,变量s
都是套接字。 父进程不再需要它,于是调用了close
, 然后返回0
通知父进程的父进程成功终止。
此时,子进程继续在后台工作。 它调用listen
并设置 backlog 为 4
。这里并不需要设置一个很大的值, 因为 daytime 不是个总有许多客户请求的协议,并且总可以立即处理每个请求。
最后,守护进程开始无休止循环,按照如下步骤:
调用accept
。 在这里等待直到一个客户端与之联系。在这里,接收一个新套接字,c
, 用来与其特定的客户通信。
使用 C 语言函数 fdopen
把套接字从一个 低级 文件描述符 转变成一个 C语言风格的 FILE
指针。 这使得后面可以使用 fprintf
。
检查时间,按 ISO 8601格式打印到 “文件” client
。 然后使用 fclose
关闭文件。这会把套接字一同自动关闭。
我们可把这些步骤 概括 起来,作为模型用于许多其它服务器:
这个流程图很好的描述了顺序服务器, 那是在某一时刻只能服务一个客户的服务器,就像我们的daytime服务器能做的那样。这只能存在于客户端与服务器没有真正的“对话”的时候:服务器一检测到一个与客户的连接,就送出一些数据并关闭连接。整个操作只花费若干纳秒就完成了。
这张流程图的好处是,除了在父进程 fork
之后和父进程退出前的短暂时间内, 一直只有一个进程活跃:我们的服务器不占用许多内存和其它系统资源。
注意我们已经将初始化守护进程 加入到我们的流程图中。我们不需要初始化我们自己的守护进程 (译者注:这里仅指上面的示例程序。一般写程序时都是需要的。), 但这是在程序流程中设置signal
处理程序、 打开我们可能需要的文件等操作的好地方。
几乎流程图中的所有部分都可以用于描述许多不同的服务器。 条目 serve 是个例外,我们考虑为一个 “黑盒子”,那是你要为你自己的服务器专门设计的东西, 并且 “接到其余部分上”。
并非所有协议都那么简单。许多协议收到一个来自客户的请求,回复请求,然后接收下一个来自同一客户的请求。 因此,那些协议不知道将要服务客户多长时间。这些服务器通常为每个客户启动一个新进程 当新进程服务它的客户时,守护进程可以继续监听更多的连接。
现在,继续,保存上面的源代码为 daytimed.c (用字母d
结束守护程序名是个风俗)。在你编译好后,尝试运行:
% ./daytimed bind: Permission denied %
这里发生了什么?正如你将回想起的, daytime协议使用端口13。 但是所有1024以下的端口保留给超级用户 (否则,任何人都可以启动一个守护进程伪装一个常用端口的服务, 这就导致了一个安全漏洞)。
再试一次,这次以超级用户的身份:
# ./daytimed #
怎么……什么都没有?让我们再试一次:
# ./daytimed bind: Address already in use #
在一个时刻,每个端口只能被一个程序绑定。我们的第一个尝试真的成功了:启动了守护子进程并安静的返回。守护子进程仍然在运行,并且继续运行到你关闭它,或是它使用的系统调用失败,或是你重启计算机时。
好,我们知道它正在后台运行着。 但是它正在正常工作吗?我们如何知道它是个正常的 daytime 服务器?只需简单的:
% telnet localhost 13 Trying ::1... telnet: connect to address ::1: Connection refused Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. 2001-06-19T21:04:42Z Connection closed by foreign host. %
telnet 尝试新协议 IPv6,失败了。又重新尝试 IPv4,而后成功了。守护进程工作正常。
如果你可以通过telnet 访问另一个 UNIX 系统,你可以用测试远程访问服务器。 我们计算机没有静态 IP 地址, 所以我这样做:
% who whizkid ttyp0 Jun 19 16:59 (216.127.220.143) xxx ttyp1 Jun 19 16:06 (xx.xx.xx.xx) % telnet 216.127.220.143 13 Trying 216.127.220.143... Connected to r47.bfm.org. Escape character is '^]'. 2001-06-19T21:31:11Z Connection closed by foreign host. %
又工作正常了。使用域名还会工作正常吗?
% telnet r47.bfm.org 13 Trying 216.127.220.143... Connected to r47.bfm.org. Escape character is '^]'. 2001-06-19T21:31:40Z Connection closed by foreign host. %
顺序说一句,telnet 在我们的守护进程关闭套接字之后打印消息 Connection closed by foreign host (连接被外部主机关闭)。这告诉我们,实际上,在我们的代码中使用 fclose(client);
的工作情况就像前面说的一样。