UNIX网络编程 卷1:套接字联网API(第3版)

978-7-115-36719-8
作者: 【美】W. Richard Stevens Bill Fenner Andrew M. Rudoff
译者: 无
编辑: 杨海玲

图书目录:

详情

本书是一部UNIX网络编程的经典之作!书中全面深入地介绍了如何使用套接字API进行网络编程。全书不但介绍了基本编程内容,还涵盖了与套接字编程相关的高级主题,对于客户/服务器程序的各种设计方法也作了完整的探讨,最后还深入分析了流这种设备驱动机制。本书内容详尽且具权威性,几乎每章都提供精选的习题,并提供了部分习题的答案,是网络研究和开发人员理想的参考书。

图书摘要

版权信息

书名:UNIX网络编程 卷1:套接字联网API(第3版)

ISBN:978-7-115-36719-8

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美] W. Richard Stevens Bill Fenner Andrew M. Rudoff

  责任编辑 杨海玲

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


本书是一部UNIX网络编程的经典之作!书中全面深入地介绍了如何使用套接字API进行网络编程。全书不但介绍了基本编程内容,还涵盖了与套接字编程相关的高级主题,对于客户/服务器程序的各种设计方法也作了完整的探讨,最后还深入分析了流这种设备驱动机制。

本书内容详尽且具权威性,几乎每章都提供精选的习题,并提供了部分习题的答案,是网络研究和开发人员理想的参考书。


Authorized translation from the English language edition, entitled UNIX Network Programming, Volume 1: The Sockets Networking API, Third Edition, 9780131411555 by W. Richard Stevens, Bill Fenner, and Andrew M. Rudoff, published by Pearson Education, Inc., publishing as Addison-Wesley, Copyright © 2004 by Pearson Education, Inc.

All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.

CHINESE SIMPLIFIED language edition published by PEARSON EDUCATION ASIA LTD. and POSTS & TELECOM PRESS Copyright © 2015.

本书中文简体字版由Pearson Education Asia Ltd.授权人民邮电出版社独家出版。未经出版者书面许可,不得以任何方式复制或抄袭本书内容。

本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签,无标签者不得销售。

版权所有,侵权必究。


本书的第1版本于1990年问世,并迅速成为程序员学习网络编程的权威参考书。时至今日,计算机网络技术已发生了翻天覆地的变化。只要看看第1版给出的用于征集反馈意见的地址(“uunet!hsi!netbook”)就一目了然了。(有多少读者能看出这是20世纪80年代很流行的UUCP拨号网络的地址?)

现在UUCP网络已经很罕见了,而无线网络等新技术则变得无处不在!在这种背景下,新的网络协议和编程范型业已开发出来,但程序员却苦于找不到一本好的参考书来学习这些复杂的新技术。

这本书填补了这一空白。拥有本书旧版的读者一定想要一个新的版本来学习新的编程方法,了解IPv6等下一代协议方面的新内容。所有人都非常期待本书,因为它完美地结合了实践经验、历史视角以及在本领域浸淫多年才能获得的透彻理解。

阅读本书是一种享受,我收获颇丰。相信大家定会有同感。

Sam Leffler


本书面向的读者是那些希望自己编写的程序能使用称为套接字(socket)的API进行彼此通信的人。有些读者可能已经非常熟悉套接字了,因为这个模型几乎已经成了网络编程的同义词,但有些读者可能仍需要从头开始学习。本书想达到的目标是向大家提供网络编程指导。这些内容不仅适用于专业人士,也适用于初学者;不仅适用于维护已有代码,也适用于开发新的网络应用程序;此外,还适用于那些只是想了解一下自己系统中网络组件的工作原理的人。

书中的所有示例都是在Unix系统上测试通过的真实的、可运行的代码。但是,考虑到许多非Unix的操作系统也支持套接字API,因而我们选取的示例与所讲述的一般性概念,在很大程度上是与操作系统无关的。几乎每种操作系统都提供了大量的网络应用程序,如网页浏览器、电子邮件客户端、文件共享服务器等。我们按常规的划分方法把这些应用程序分为客户程序和服务器程序,并在书中多次编写了相应的小型示例。

面向Unix介绍网络编程自然免不了要介绍Unix本身和TCP/IP的相关背景知识。需要更详尽的背景知识时,我们会指引读者查阅其他书籍。本书中经常提到以下4本书,我们将其简记如下:

其中TCPv2包含了与本书内容密切相关的细节,它描述并给出了套接字API中网络编程函数(socketbindconnect等)的真实4.4BSD实现。如果已经理解某个特性的实现,那么在应用程序中使用该特性就更有意义了。

从20世纪80年代开始,套接字就差不多是现在这个样子了。时至今日,套接字仍然是网络API的首选,其最初的设计的确值得称道。因此,当读者发现我们对出版于1998年的第2版又做了不少改动时,可能会觉得惊讶。本书中所做的改动归纳如下。

这些机器的具体用法见图1-16。

本系列的第2卷(《UNIX网络编程 卷2:进程间通信》)基于本卷的内容进一步讨论了消息传递、同步、共享内存及远程过程调用。

本书既可以作为网络编程的教程,也可以作为有经验的程序员的参考书。用作网络编程的教程或入门级教材时,重点应放在第二部分(第3章至第11章),然后可以看看其他感兴趣的主题。第二部分包含了TCP和UDP的基本套接字函数,以及SCTP、I/O多路复用、套接字选项和基本名字与地址的转换。所有读者都应该阅读第1章,尤其是1.4节,介绍了一些贯穿全书的包裹函数。读者可以根据自身的知识背景,选读第2章,或许还有附录A。第三部分的多数章节可以彼此独立地进行阅读。

为了方便读者把本书作为参考书,本书提供了完整的全文索引,并在最后几页总结了每个函数和结构的详细描述在正文中的哪里可以找到。为了给不按顺序阅读本书的读者提供方便,我们在全书中为相关主题提供了大量的交叉引用。

书中所有示例的源代码可以从www.unpbook.com获得。学习网络编程的最好方法就是下载这些程序,对其进行修改和改进。只有这样实际编写代码才能深入理解有关概念和方法。每章末尾提供了大量的习题,大部分在附录E中给出答案。

本书的最新勘误表也可以在上述网站获取。

本书第1版和第2版由W. Richard Stevens独立撰写,他不幸于1999年9月1日去世。Richard的著作体现了非常高的水准,被公认为是精炼、详实且极具可读性的艺术作品。在撰写这一修订版的过程中,我们力图保持Richard之前版本的高质量和全面性,这方面的任何不足都完全是新作者的过错。

任何作者的著作离不开家人与朋友的支持。Bill Fenner在此感谢爱妻Peggy(沙滩1/4英里赛冠军)与好友Christopher Boyd在本书撰写过程中承担了全部的家务,还要感谢朋友Jerry Winner,他的激励是无价的。同样地,Andy Rudoff要特别感谢他的妻子Ellen和两个女儿Jo、Katie自始至终的理解与鼓励。没有你们的支持,我们不可能完成本书。

思科公司的Randall Stewart提供了许多SCTP的材料,非常感谢他的巨大贡献。如果缺少了他的工作,本书就不能涵盖这一新颖而有趣的主题。

本书的审稿人给出了宝贵的反馈意见。他们发现了一些错误,指出了一些需要更多解释的地方,并对文字和代码示例提出了一些改进建议。作者在这里对如下审稿人表示感谢:James Carlson、Wu-Chang Feng、Rick Jones、Brian Kernighan、Sam Leffler、John McCann、Craig Metz、Ian Lance Taylor、David Schwartz和Gary Wright。

许多个人及其单位为本书中一些示例的测试提供了帮助,他们义务向我们出借系统、软件或为我们提供系统访问权限。

与Addison Wesley出版社的员工合作非常愉快,他们是Noreen Regina、Kathleen Caren、Dan DePasquale和Anthony Gemellaro。要特别感谢本书的编辑Mary Franz。

为了延续Rich Stevens的风格(不过该风格与流行的风格相反),我们用James Clark编写的优秀的Groff包为本书排版,用gpic程序绘制插图(其中用到了许多由Gary Wright编写的宏),用gtbl程序生成了表格,我们为全书添加了索引,并设计了最终的版式。录入源代码时用到了Dave Hanson的loom程序和Gary Wright写的一些脚本。在生成最终索引的过程中,还用到了Jon Bentley与Brian Kernighan编写的一组awk脚本。

欢迎读者以电子邮件的方式反馈意见、提出建议或订正错误。

Bill Fenner

加利福尼亚州伍德赛德市

Andrew M. Rudoff

科罗拉多州博尔德市

2003年10月

authors@unpbook.com

http://www.unpbook.com

书中所有示例的源代码也可以从图灵网站(www.turingbook.com)本书网页免费注册下载。——编者注



要编写通过计算机网络通信的程序,首先要确定这些程序相互通信所用的协议(protocol)。在深入设计一个协议的细节之前,应该从高层次决断通信由哪个程序发起以及响应在何时产生。举例来说,一般认为Web服务器程序是一个长时间运行的程序(即所谓的守护程序,daemon),它只在响应来自网络的请求时才发送网络消息。协议的另一端是Web客户程序,如某种浏览器,与服务器进程的通信总是由客户进程发起。大多数网络应用就是按照划分成客户(client)和服务器(server)来组织的。在设计网络应用时,确定总是由客户发起请求往往能够简化协议和程序本身。当然一些较为复杂的网络应用还需要异步回调(asynchron-ous callback)通信,也就是由服务器向客户发起请求消息。然而坚持采纳图1-1所示的基本客户/服务器模型的网络应用毕竟要普遍得多。

图1-1 网络应用:客户和服务器

通常客户每次只与一个服务器通信,不过以使用Web浏览器为例,我们也许在10分钟内就可以与许多不同的Web服务器通信。从服务器的角度来看,一个服务器同时与多个客户通信并不稀奇,见图1-2。本书后面将介绍若干种让一个服务器同时处理多个客户请求的方法。

3

图1-2 一个服务器同时处理多个客户的请求

可认为客户与服务器之间是通过某个网络协议通信的,但实际上,这样的通信通常涉及多个网络协议层。本书的焦点是TCP/IP协议族,也称为网际协议族。举例来说,Web客户与服务器之间使用TCP(Transmission Control Protocol,传输控制协议)通信。TCP又转而使用IP(Internet Protocol,网际协议)通信,IP再通过某种形式的数据链路层通信。如果客户与服务器处于同一个以太网,就有图1-3所示的通信层次。

图1-3 客户与服务器使用TCP在同一个以太网中通信

尽管客户与服务器之间使用某个应用协议通信,传输层却使用TCP通信。注意,客户与服务器之间的信息流在其中一端是向下通过协议栈的,跨越网络后,在另一端则是向上通过协议栈的。另外注意,客户和服务器通常是用户进程,而TCP和IP协议通常是内核中协议栈的一部分。我们在图1-3右边标出了4个层。

4

本书讨论的协议不限于TCP和IP。有些客户和服务器改用UDP(User Datagram Protocol,用户数据报协议)而不是TCP,第2章将详细介绍这两个协议。此外,本书使用术语“IP”来称谓的那个协议,自20世纪80年代早期以来一直在使用,其实其正式名称是IPv4(IP version 4,IP版本4)。IPv4的一个新版本IPv6(IP version 6,IP版本6)是在20世纪90年代中期开发出来的,将来会取代IPv4。本书既讨论使用IPv4的网络应用程序的开发,也讨论使用IPv6的网络应用程序的开发。附录A会给出IPv4和IPv6的一个比较,同时介绍正文中将讨论的其他协议。

同一网络应用的客户和服务器无需如图1-3所示处于同一个局域网(local area network,LAN)。例如,图1-4展示了处于不同局域网中的客户和服务器,而这两个局域网是使用路由器(router)连接到广域网(wide area network,WAN)的。

图1-4 处于不同局域网的客户主机和服务器主机通过广域网连接

路由器是广域网的架构设备。当今最大的广域网是因特网(Internet)。许多公司也构建自己的广域网,而这些私用的广域网既可以连接到因特网,也可以不连接到因特网。

本章其余部分将概述多个主题,这些主题在后续章节中还会具体介绍。我们从一个尽管简单却完整的TCP客户程序开始,它展示了全书都会遇到的许多函数调用和概念。这个客户程序只能在IPv4上运行,不过我们会给出让它在IPv6上运行所需进行的修改。更好的办法是编写独立于协议的客户和服务器程序,这在第11章中会讨论。本章同时展示一个与该TCP客户程序配合工作的完整的TCP服务器程序。

5

为了简化代码,我们对本书中要调用的大多数系统函数定义了各自的包裹函数。多数情况下我们可以使用这些包裹函数来检查错误,输出适当的消息,以及在出错时终止程序的运行。我们还给出了本书中大多数例子所用的测试网络、主机、路由器以及它们的主机名、IP地址和操作系统。

如今讨论Unix时经常使用POSIX一词,它是一种被多数厂商采纳的标准。我们将介绍POSIX的历史以及它对本书所讲述的API的影响,并介绍该领域的其他主要标准。

让我们考虑一个具体的例子,引入将在本书中遇到的许多概念和说法。图1-5所示的是TCP当前时间查询客户程序的一个实现。该客户与其服务器建立一个TCP连接后,服务器以直观可读格式简单地送回当前时间和日期。

图1-5 TCP时间获取客户程序

6

这就是本书用于展示所有源代码的格式。每个非空行都被编排行号。如稍后所示,代码正文讲解部分一开始标注该段代码起始与结束的行号。有的段落会以一个简短的、描述性的醒目标题起头,对所讲解代码段进行概要说明。

每个源代码段起始与结束处的水平线标出了该代码段所在的源代码文件名,对于本例就是intro目录下的daytimetcpcli.c文件(intro/daytimetcpcli.c)。本书所有例子的源代码都可免费获得(见前言),在此标注它们的文件名便于读者找到其源文件。在阅读本书期间,编译、运行特别是修改这些程序是学习网络编程概念的好方法。

整本书中我们随时会插入缩进的小字号段落(如此处所示)来说明实现的细节和历史上的观点。

如果编译该程序生成默认的a.out可执行文件后执行它,我们会得到如下结果:

solaris % a.out 206.168.112.96        我们的输入
Mon May 26 20:58:40 2003           程序的输出

当我们展示交互的输入和输出时,输入总是采用加粗的等宽字体,而计算机的输出总是采用不加粗的等宽字体。注释用宋体字加在右边。作为shell提示一部分的系统名字(本例中为solaris)指明在哪个主机上执行该命令。图1-16展示了用于运行本书中大多数例子的各个系统,它们的主机名本身通常就说明了各自的操作系统。

在这个短短27行的程序中有许多细节值得考虑。这里我们简短地提一下,目的是让初次遇到网络程序的读者有所准备,本书后面会更详细地说明这些内容。

包含头文件

1 包含我们自己编写的名为unp.h的头文件,见D.1节。该头文件包含了大部分网络程序都需要的许多系统头文件,并定义了所用到的各种常值(如MAXLINE)。

命令行参数

2~3 这是main函数的定义,其形式参数就是命令行参数。本书中的代码假设使用ANSI C编译器(也称为ISO C编译器)编写。

创建TCP套接字

10~11 socket函数创建一个网际(AF_INET)字节流(SOCK_STREAM)套接字,它是TCP套接字的花哨名字。该函数返回一个小整数描述符,以后的所有函数调用(如随后的connectread)就用该描述符来标识这个套接字。

7

if语句包含3个操作:调用socket函数,把返回值赋给变量sockfd,再测试所赋的这个值是否小于0。虽然我们可以把该语句分割成两条C语句:

sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)

但是把这两行合并成一行却是常见的C语言习惯用法。按照C语言的优先规则(小于运算符的优先级高于赋值运算符),函数调用和赋值语句外边的那对括号是必需的。作为一种编码风格,作者总是在这样的两个左括号间加一个空格,提示比较运算的左侧同时也是一个赋值运算。(这种风格借鉴自Minix源代码[Tenenbaum 1987]。)该程序稍后的while语句也使用相同的样式。

后面我们将遇到术语套接字(socket)的许多不同用法。首先,我们正在使用的API称为套接字API(sockets API)。上一段中名为socket的函数就是套接字API的一部分。上一段中我们还提到了“TCP套接字”,它是“TCP端点”(TCP endpoint)的同义词。

如果socket函数调用失败,我们就调用自己的err_sys函数放弃程序运行。err_sys函数输出我们作为参数提供的出错消息以及所发生的系统错误的描述(例如出自socket函数的可能错误之一“Proto-col not supported”(协议不受支持)),然后终止进程。这个函数和以err_开头的其他若干个函数都是我们自行编写的,它们的调用将贯穿全书,D.3节会描述这些函数。

指定服务器的IP地址和端口

12~16 我们把服务器的IP地址和端口号填入一个网际套接字地址结构(一个名为servaddrsockaddr_in结构变量)。使用bzero把整个结构清零后,置地址族为AF_INET,端口号为13(这是时间获取服务器的众所周知端口,支持该服务的任何TCP/IP主机都使用这个端口号,见图2-18),IP地址为第一个命令行参数的值(argv[1])。网际套接字地址结构中IP地址和端口号这两个成员必须使用特定格式,为此我们调用库函数htons(“主机到网络短整数”)去转换二进制端口号,又调用库函数inet_pton(“呈现形式到数值”)去把ASCII命令行参数(例如运行本例子所用的206.168.112.96)转换为合适的格式。

bzero不是一个ANSI C函数。它起源于早期的Berkeley网络编程代码。不过我们在整本书中使用它而不用ANSI C的memset函数,因为bzero(带2个参数)比memset(带3个参数)更好记忆。几乎所有支持套接字API的厂商都提供bzero,如果没有,那么可以使用unp.h头文件中提供的该函数的宏定义。

事实上,在TCPv3一书首次印刷时,作者在10处出现memset函数的地方犯了错,互换了第二和第三个参数。C编译器发现不了这个错误,因为这两个参数的类型是相同的。(其实第二个参数是int类型,第三个参数是size_t,通常定义为unsigned int类型,然而分别指定给这两个参数的值为0和16,它们对于两个参数的类型同样可以接受。)对memset的这些调用仍然正常,不过没做任何事,因为待初始化的字节数被指定成了0。程序之所以仍然工作是因为只有少数套接字函数要求网际套接字地址结构的最后8个字节置0。无论如何,这确实是一个错误,且是一个通过使用bzero函数可以避免的错误,因为如果使用函数原型,C编译器总能发现bzero的两个参数被互换的错误。

8

此处也许是你第一次遇到inet_pton函数。它是一个支持IPv6(详见附录A)的新函数。以前的代码使用inet_addr函数来把ASCII点分十进制数串变换成正确的格式,不过它有不少局限,而这些局限在inet_pton中都得以纠正。如果你的系统尚未支持该函数,那你可以使用我们在3.7节中提供的它的一个实现。

建立与服务器的连接

17~18 connect函数应用于一个TCP套接字时,将与由它的第二个参数指向的套接字地址结构指定的服务器建立一个TCP连接。该套接字地址结构的长度也必须作为该函数的第三个参数指定,对于网际套接字地址结构,我们总是使用C语言的sizeof操作符由编译器来计算这个长度。

在头文件unp.h中,我们使用#defineSA定义为struct sockaddr,也就是通用套接字地址结构。每当一个套接字函数需要一个指向某个套接字地址结构的指针时,这个指针必须强制类型转换成一个指向通用套接字地址结构的指针。这是因为套接字函数早于ANSI C标准,20世纪80年代早期开发这些函数时,ANSI C的void *指针类型还不可用。问题是“struct sockaddr”长达15个字符,往往造成源代码行超出屏幕(或者书页,若是排印在书上)的右边缘,因此我们把它缩减成SA。我们将在解释图3-3时详细讨论通用套接字地址结构。

读入并输出服务器的应答

19~25 我们使用read函数读取服务器的应答,并用标准的I/O函数fputs输出结果。使用TCP时必须小心,因为TCP是一个没有记录边界的字节流协议。服务器的应答通常是如下格式的26字节字符串:

Mon May 26 20:58:40 2003\r\n

其中,\r是ASCII回车符,\n是ASCII换行符。使用字节流协议的情况下,这26个字节可以有多种返回方式:既可以是包含所有26个字节的单个TCP分节,也可以是每个分节只含1个字节的26个TCP分节,还可以是总共26个字节的任何其他组合。通常服务器返回包含所有26个字节的单个分节,但是如果数据量很大,我们就不能确保一次read调用能返回服务器的整个应答。因此从TCP套接字读取数据时,我们总是需要把read编写在某个循环中,当read返回0(表明对端关闭连接)或负值(表明发生错误)时终止循环。

本例中,服务器关闭连接表征记录的结束。HTTP(Hypertext Transfer Protocol,超文本传送协议)的1.0版本也采用这种技术。还可以用其他技术标记记录结束。例如,SMTP(Simple Mail Transfer Protocol,简单邮件传送协议)使用由ASCII回车符后跟换行符构成的2字节序列标记记录的结束;Sun远程过程调用(Remote Procedure Call,RPC)以及域名系统(Domain Name System,DNS)在使用TCP承载应用数据时,在每个要发送的记录之前放置一个二进制的计数值,给出这个记录的长度。这里的重要概念是TCP本身并不提供记录结束标志:如果应用程序需要确定记录的边界,它就要自己去实现,已有一些常用的方法可供选择。

终止程序

26 exit终止程序运行。Unix在一个进程终止时总是关闭该进程所有打开的描述符,我们的TCP套接字就此被关闭。

9

刚才已提过,本书后面会对刚才讲述的所有概念深入进行探讨。

图1-5中的程序是与IPv4协议相关的:我们分配并初始化一个sockaddr_in类型的结构,把该结构的协议族成员设置为AF_INET,并指定socket函数的第一个参数为AF_INET

为了让图1-5中的程序能够在IPv6上运行,我们必须修改这段代码。图1-6所示的是一个能够在IPv6上运行的版本,其中改动之处用加粗的等宽字体突出显示。

图1-6 适合于IPv6的图1-5所示程序的修改版

我们只修改了程序的5行代码,得到的却是另一个与协议相关的程序:这回是与IPv6协议相关的。更好的做法是编写协议无关的程序。图11-11将给出本客户程序的协议无关版本,它使用了getaddrinfo函数(由tcp_connect函数调用)。

10

这两个程序的另一个不足之处是:用户必须以点分十进制数格式给出服务器的IP地址(如适合于IPv4版本的206.168.112.219)。人们更习惯于用名字(如www.unpbook.com)来代替数字。我们将在第11章中讨论主机名与IP地址之间以及服务名与端口之间的转换函数。我们特意推迟讨论这些函数,在第11章之前继续使用IP地址和端口号,目的是了解我们必须填写和查看的套接字地址结构的细节,避免被另一个函数集的细节把网络编程的讨论搞复杂了。

任何现实世界的程序都必须检查每个函数调用是否返回错误。在图1-5所示的程序中,我们检查socketinet_ptonconnectreadfputs函数是否返回错误,当发生错误时,就调用我们自己的err_quiterr_sys函数输出一个出错消息并终止程序的运行。我们发现绝大多数情况下这正是我们想做的事。个别情况下,当这些函数返回错误时,我们想做的事并非简单地终止程序的运行,如图5-12所示,我们必须检查系统调用是否被中断了。

既然发生错误时终止程序的运行是普遍的情况,我们可以通过定义包裹函数(wrapper function)来缩短程序。每个包裹函数完成实际的函数调用,检查返回值,并在发生错误时终止进程。我们约定包裹函数名是实际函数名的首字母大写形式。例如,在语句

sockfd = Socket(AF_INET, SOCK_STREAM, 0);

中,函数Socket是函数socket的包裹函数,如图1-7所示。

图1-7 socket函数的包裹函数

在本书中只要你遇到一个首字母大写的函数名,它就是我们定义的某个包裹函数。它调用的实际函数的名字与包裹函数名相同,不过以对应的小写字母开头。

然而在讲解本书中提供的源代码时,我们总是指称被调用的最低级别的函数(如socket),而不是包裹函数(如Socket)。

11

这些包裹函数不见得多节省代码量,但当我们在第26章中讨论线程时,将会发现线程函数遇到错误时并不设置标准Unix的errno变量,而是把errno的值作为函数返回值返回调用者。这意味着每次调用以pthread_开头的某个函数时,我们必须分配一个变量来存放函数返回值,以便在调用err_sys前把errno变量设置成该值。为避免引入花括号把代码弄得很混乱,我们可以使用C语言的逗号操作符,把errno的赋值与err_sys的调用组合成一条语句,如下所示:

int  n;

if ( (n = pthread_mutex_lock(&ndone_mutex)) != 0)
     errno = n, err_sys("pthread_mutex_lock error");

我们也可以为此定义一个新的错误处理函数,它取系统的错误号作为一个参数,不过通过定义如图1-8所示的包裹函数,我们可以让以上这段代码更为易读:

图1-8 pthread_mutex_lock的包裹函数

Pthread_mutex_lock(&ndone_mutex);

要是仔细推敲C代码的编写,我们可以用宏来替代函数,从而稍微提高运行时效率,不过包裹函数很少是程序性能的瓶颈所在。

选择首字母大写一个函数名作为其包裹函数名是一种折中的方法。其他方法也考虑过,譬如给函数名加一个“e”前缀(如[Kernighan and Pike 1984]一书第182页所示),给函数名加一个“_e”后缀,等等。这些方法都能明显地提示调用了其他函数,但我们的这种风格看来是最少分散注意力的。

这种技术还有助于检查那些错误返回值通常被忽略的函数是否出错,例如closelisten

本书后面的例子中,除非必须检查某个确定的错误是否发生,并以不同于终止进程的其他某种方式处理它,否则就使用这些包裹函数。书中不提供所有包裹函数的源代码,不过它们是可以免费获得的(见前言)。

12

只要一个Unix函数(例如某个套接字函数)中有错误发生,全局变量errno就被置为一个指明该错误类型的正值,函数本身则通常返回1。err_sys查看errno变量的值并输出相应的出错消息,例如当errno值等于ETIMEDOUT时,将输出“Connection timed out”(连接超时)。

errno的值只在函数发生错误时设置。如果函数不返回错误,errno的值就没有定义。errno的所有正数错误值都是常值,具有以“E”开头的全大写字母名字,并通常在< sys/errno.n >头文件中定义。值0不表示任何错误。

在全局变量中存放errno值对于共享所有全局变量的多个线程并不适合。我们将在第26章中讲述解决这一问题的方法。

全书中我们将使用诸如“connect函数返回ECONNREFUSED”这样的句子简明表达以下意思:该函数返回一个错误(通常函数返回值为-1),同时errno被置为指定的常值。

我们可以编写一个简单的TCP时间获取服务器程序,它和1.2节中的客户程序一道工作。图1-9给出了这个服务器程序,它使用了上一节中讲过的包裹函数。

图1-9 TCP时间获取服务器程序

创建TCP套接字

10 TCP套接字的创建与客户程序相同。

把服务器的众所周知端口捆绑到套接字

11~15 通过填写一个网际套接字地址结构并调用bind函数,服务器的众所周知端口(对于时间获取服务是13)被捆绑到所创建的套接字。我们指定IP地址为INADDR_ANY,这样要是服务器主机有多个网络接口,服务器进程就可以在任意网络接口上接受客户连接。以后我们将了解怎样限定服务器进程只在单个网络接口上接受客户连接。

把套接字转换成监听套接字

16 调用listen函数把该套接字转换成一个监听套接字,这样来自客户的外来连接就可在该套接字上由内核接受。socketbindlisten这3个调用步骤是任何TCP服务器准备所谓的监听描述符(listening descriptor,本例中为listenfd)的正常步骤。

常值LISTENQ在我们的unp.h头文件中定义。它指定系统内核允许在这个监听描述符上排队的最大客户连接数。我们将在4.5节详细说明客户连接的排队。

接受客户连接,发送应答

17~21 通常情况下,服务器进程在accept调用中被投入睡眠,等待某个客户连接的到达并被内核接受。TCP连接使用所谓的三路握手(three-way handshake)来建立连接。握手完毕时accept返回,其返回值是一个称为已连接描述符(connected descriptor)的新描述符(本例中为connfd)。该描述符用于与新近连接的那个客户通信。accept为每个连接到本服务器的客户返回一个新描述符。

本书全文采用的无限循环采用以下风格:

for ( ; ; ) {
    . . .
}
13~14

当前时间和日期是由库函数time返回的,它实际上返回的是自Unix纪元即0点0分0秒(国际标准时间)以来的秒数。下一个库函数ctime把该整数值转换成直观可读的时间格式,例如:

Mon May 26 20:58:40 2003

snprintf函数在这个字符串末尾添加一个回车符和一个回行符,随后write函数把结果字符串写给客户。

如果你尚不习惯改用snprintf代替较早的sprintf函数,那么现在是学习的时候了。调用sprintf无法检查目的缓冲区是否溢出。相反,snprintf要求其第二个参数指定目的缓冲区的大小,因此可确保该缓冲区不溢出。

snprintf相对较晚才加到ANSI C标准中,在称为ISO C99的版本中引入。不过几乎所有厂商都把它作为标准C函数库的一部分提供,而且另有许多免费可得的版本可用。我们贯穿全书使用snprintf,也推荐你出于可靠性考虑在自己的程序中改用它来代替sprintf

值得注意的是,许多网络入侵是由黑客通过发送数据,导致服务器对sprintf的调用使其缓冲区溢出而发生的。必须小心使用的函数还有getsstrcatstrcpy,通常应分别改为调用fgetsstrncatstrncpy。更好的替代函数是后来才引入的strlcatstrlcpy,它们确保结果是正确终止的字符串。编写安全的网络程序的更多技巧参见[Garfinkel, Schwartz, and Spafford 2003]的第23章。

终止连接

22 服务器通过调用close关闭与客户的连接。该调用引发正常的TCP连接终止序列:每个方向上发送一个FIN,每个FIN又由各自的对端确认。2.6节将详细讲述TCP的三路握手和用于终止一个TCP连接的4个TCP分组。

与上节查看客户程序一样,本节查看服务器程序也非常简略,具体细节留待本书以后论述。有以下几点需要注意。

15

贯穿全书的用于阐述网络编程中使用的各种技术的两个客户/服务器程序示例如下:

为了提供本书所涵盖不同主题的路线图,我们用下面4个表格汇总了将要开发的程序,并给出了它们的源代码所在的起始图号。图1-10列出了本书开发的时间获取客户程序的不同版本,其中有两个版本前面已讲过。图1-11列出了时间获取服务器程序的不同版本。图1-12列出了回射客户程序的不同版本,图1-13列出了回射服务器程序的不同版本。

图1-10 本书开发的时间获取客户程序的不同版本

16

图1-11 本书开发的时间获取服务器程序的不同版本

图1-12 本书开发的回射客户程序的不同版本

此处保留了本书第2版的内容。——译者注

图1-13 本书开发的回射服务器程序的不同版本

描述一个网络中各个协议层的常用方法是使用国际标准化组织(International Organization for Standardization,ISO)的计算机通信开放系统互连(open systems interconnection,OSI)模型。这是一个七层模型,如图1-14所示。图中同时给出了它与网际协议族的近似映射。

图1-14 OSI模型和网际协议族中的各层

我们认为OSI模型的底下两层是随系统提供的设备驱动程序和网络硬件。通常情况下,除需知道数据链路的某些特性外(如将在2.11节论述的1500字节以太网的MTU大小),我们不必关心这两层的具体情况。

网络层由IPv4和IPv6这两个协议处理,我们将在附录A中讲述它们。可以选择的传输层有TCP或UDP,我们将在第2章中讲述它们。图1-14中TCP与UDP之间留有间隙,表明网络应用绕过传输层直接使用IPv4或IPv6是可能的。这就是所谓的原始套接字(raw socket),我们将在第28章中讨论。

OSI模型的顶上三层被合并成一层,称为应用层。这就是Web客户(浏览器)、Telnet客户、Web服务器、FTP服务器和其他我们在使用的网络应用所在的层。对于网际协议,OSI模型的顶上三层协议几乎没有区别。

本书讲述的套接字编程接口是从顶上三层(网际协议的应用层)进入传输层的接口。本书的焦点是:如何使用套接字编写使用TCP或UDP的网络应用程序。我们已提到原始套接字,在第29章中我们将看到,甚至可以彻底绕过IP层直接读写数据链路层的帧。

为什么套接字提供的是从OSI模型的顶上三层进入传输层的接口?这样设计有两个理由,如图1-14右侧所注。理由之一是顶上三层处理具体网络应用(如FTP、Telnet或HTTP)的所有细节,却对通信细节了解很少;底下四层对具体网络应用了解不多,却处理所有的通信细节:发送数据,等待确认,给无序到达的数据排序,计算并验证校验和,等等。理由之二是顶上三层通常构成所谓的用户进程(user process),底下四层却通常作为操作系统内核的一部分提供。Unix与其他现代操作系统都提供分隔用户进程与内核的机制。由此可见,第4层和第5层之间的接口是构建API的自然位置。

17~19

套接字API起源于1983年发行的4.2BSD操作系统。图1-15展示了各种BSD发行版本的发展史,并注明了TCP/IP的主要发展历程。1990年面世的4.3BSD Reno发行版本随着OSI协议进入BSD内核而对套接字API做了少量的改动。

图1-15 各种BSD版本的历史

图1-15中从4.2BSD往下到4.4BSD的通路展示了源自Berkeley计算机系统研究组(Computer Systems Research Group,CSRG)的各个版本,它们要求获取者已拥有Unix的源代码许可权。然而其中的所有网络支持代码,不论是内核支持(如TCP/IP协议栈、Unix域协议栈及套接字API)还是应用程序(如Telnet和FTP客户和服务器程序)都是独立于源自AT&T的Unix代码开发的。因此从1989年起,Berkeley开始提供第一个BSD网络支持版本,它包含所有的网络支持代码以及不受Unix源代码许可权约束的其他各种BSD系统软件。这些包含网络支持代码的版本是可公开获取的,最终因特网上任何人都可通过匿名FTP获取。

源自Berkeley的最终版本是1994年的4.4BSD-Lite和1995年的4.4BSD-Lite2。我们指出这两个版本是其他多个系统(包括BSD/OS、FreeBSD、NetBSD和OpenBSD)的基础,这些系统大多数仍然处于活跃的开发和完善之中。有关各种BSD版本和各种Unix系统历史的详情参见[Mckusick et al.1996]的第1章。

许多Unix系统从某个版本的BSD网络支持代码(包括套接字API)开始提供网络支持,我们称这些实现为源自Berkeley的实现(Berkeley-derived implementation)。许多商业版本的Unix是基于System V版本4(System V Release 4,SVR4)的,其中有一些系统使用源自Berkeley的网络支持代码(如UnixWare 2.x),其他SVR4系统的网络支持代码却是独立起源的(如Solaris 2.x)。我们还要注意,Linux这种流行的可免费获得的Unix实现并不适合归属源自Berkeley的系列,因为它的网络支持代码和套接字API都是从头开始开发的。

20~21

图1-16展示了本书示例所用的各个网络和主机。对于每个主机,我们都标出了它的操作系统和硬件类型(因为有些操作系统可运行在不止一种硬件上)。各个框内的名字就是出现在本书中的各个主机名。

图1-16 本书示例所用的网络和主机

图1-16所示的拓扑适合本书的例子,不过机器大范围地散布在因特网上,物理拓扑实际上变得不太重要。事实上虚拟专用网络(virtual private network,VPN)或安全shell(secure shell,SSH)连接提供这些机器之间的连通性,而无需顾及这些主机的物理位置。

图中“/24”(和/64)指出从地址的最左位开始用于标识网络和子网的连续位数。A.4节将说明现今用于指定子网边界的/n记法。

Sun操作系统的真实名字是SunOS 5.x,而不是Solaris 2.x,但是大家习惯称它为Solaris,实际上这是操作系统和与之捆绑的其他软件的合称。

22

图1-16展示了本书的全部示例所用主机的网络拓扑,但是为了在你自己的网络上运行这些例子和完成习题,你可能需要了解自己的网络拓扑。尽管目前还没有关于网络配置和管理的现行Unix标准,但大多数Unix系统都提供了可用于发现某些网络细节的两个基本命令:netstatifconfig。通过阅读所用系统上这些命令的手册页面,你可以获悉有关它们的输出信息的详情。要留意的是,有些厂商把这些命令存放在诸如/sbin/usr/sbin这样的管理目录中,而不是通常的/usr/bin目录,而这些管理目录可能不在通常的shell搜索路径中(由PATH环境变量指定)。

(1)netstat -i提供网络接口的信息。我们还指定-n标志以输出数值地址,而不是试图把它们反向解析成名字。下面的例子给出了接口及其名字和统计信息:

linux % netstat -ni
Kernel Interface table
Iface  MTU Met  RX-OK RX-ERR RX-DRP RX-OVR  -OK TX-ERR -OVR Flg
eth0  1500  049211085   0   0   040540958   0   0   0 BMRU
lo  16436  098613572   0   0   098613572   0   0   0 LRU

其中环回(loopback)接口称为lo,以太网接口称为eth0。下面的例子给出了支持IPv6的一个主机的类似信息:

freebsd % netstat -ni
Name  Mtu Network    Address        Ipkts Ierrs  Opkts Oerrs Coll
hme0  1500 <Link#1>   08:00:20:a7:68:6b 29100435  35 46561488   0   0
hme0  1500 12.106.32/24 12.106.32.254   28746630   - 46617260   -   -
hme0  1500 fe80:1::a00:20ff:fea7:686b/64
              fe80:1::a00:20ff:fea7:686b
                             0   -    0   -   -
hme0  1500 3ffe:b80:8d:1::1/64
              3ffe:b80:8d:1::1      0   -    0   -   -
hme1  1500 <Link#2>   08:00:20:a7:68:6b    51092   0  31537   0   0
hme1  1500 fe80:2::a00:20ff:fea7:686b/64
              fe80:2::a00:20ff:fea7:686b
                            0   -    90   -   -
hme1  1500 192.168.42  192.168.42.1      43584   -  24173   -   -
hme1  1500 3ffe:b80:8d:2::1/64
              3ffe:b80:8d:2::1     78   -    8   -   -
lo0  16384 <Link#6>                10198   0  10198   0   0
lo0  16384 ::1/128     ::1           10   -    10   -   -
lo0  16384 fe80:6::1/64  fe80:6::1        0   -    0   -   -
lo0  16384 127       127.0.0.1       10167   -  10167   -   -
gif0  1280 <Link#8>                  6   0    5   0   0
gif0  1280 3ffe:b80:3:9ad1::2/128
              3ffe:b80:3:9ad1::2     0   -    0   -   -
gif0  1280 fe80:8::a00:20ff:fea7:686b/64
              fe80:8::a00:20ff:fea7:686b
                             0   -    0   -   - 

注意:为了对齐输出字段,我们对较长的代码行做了回行处理。

23

(2)netstat –r展示路由表,也是另一种确定接口的方法。我们通常指定-n标志以输出数值地址。它还给出默认路由器的IP地址。

freebsd % netstat -nr
Routing tables

Internet:
Destination   Gateway        Flags  Refs  Use  Netif  Expire
default     12.106.32.1      USGc    10  6877 hme0
12.106.32/24  link#1         UC      3   0  hme0
12.106.32.1   00:b0:8e:92::00    UHLW    9   7  hme0   1187
12.106.32.253  08:00:20:b8:f7:e0  UHLW    0   1  hme0   140
12.106.32.254  08:00:20:a7:68:6b  UHLW    0   2   lo0
127.0.0.1    127.0.0.1        UH     1 10167   lo0
192.168.42   link#2          UC     2   0   hme1
192.168.42.1  08:00:20:a7:68:6b  UHLW    0   11   lo0
192.168.42.2  00:04:ac:17:bf:38  UHLW    2 24108  hme1   210

Internet6:
Destination             Gateway        Flags  Netif Expire
::/96                 ::1          UGRSc   lo0 =>
default               3ffe:b80:3:9ad1::1 UGSc   gif0
::1                  ::1          UH    lo0
::ffff:.0/96               ::1          UGRSc   lo0
3ffe:b80:3:9ad1::1         3ffe:b80:3:9ad1::2 UH    gif0
3ffe:b80:3:9ad1::2         link#8         UHL    lo0
3ffe:b80:8d::/48           lo0           USc    lo0
3ffe:b80:8d:1::/64          link#1         UC    hme0
3ffe:b80:8d:1::1           08:00:20:a7:68:6b  UHL    lo0
3ffe:b80:8d:2::/64          link#2         UC    hme1
3ffe:b80:8d:2::1            08:00:20:a7:68:6b  UHL    lo0
3ffe:b80:8d:2:204:acff:fe17:bf38  00:04:ac:17:bf:38  UHLW   hme1
fe80::/10                ::1          UGRSc   lo0
fe80::%hme0/64             link#1         UC    hme0
fe80::a00:20ff:fea7:686b%hme0    08:00:20:a7:68:6b  UHL    lo0
fe80::%hme1/64             link#2         UC    hme1
fe80::a00:20ff:fea7:686b%hme1    08:00:20:a7:68:6b  UHL    lo0
fe80::%lo0/64             fe80::1%lo0      Uc    lo0
fe80::1%lo0              link#6          UHL    lo0
fe80::%gif0/64            link#8          UC    gif0
fe80::a00:20ff:fea7:686b%gif0    link#8         UHL    lo0
ff01::/32                ::1          U     lo0
ff02::/16                ::1           UGRS   lo0
ff02::%hme0/32            link#1          UC    hme0
ff02::%hme1/32            link#2          UC    hme1
ff02::%lo0/32             ::1           UC    lo0
ff02::%gif0/32            link#8          UC    gif0
24

(3) 有了各个网络接口的名字,执行ifconfig就可获得每个接口的详细信息。

linux % ifconfig eth0
eth0   Link encap:Ethernet HWaddr 00:C0::06:B0:E1
     inet addr:206.168.112.96 Bcast:206.168.112.127 Mask:255.255.255.128
     UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
     RX packets:49214397 errors:0 dropped:0 overruns:0 frame:0
     TX packets:40543799 errors:0 dropped:0 overruns:0 carrier:0
     collisions:0 txqueuelen:100
     RX bytes:1098069974 (1047.2 Mb) TX bytes:3360546472 (3204.8 Mb)
     Interrupt:11 Base address:0x6000

该命令给出了指定接口的IP地址、子网掩码和广播地址。其中的MULTICAST标志通常指明该接口所在主机支持多播。有些ifconfig的实现还提供-a标志,用于输出所有已配置接口的信息。

(4) 找出本地网络中众多主机的IP地址的方法之一是,针对从上一步找到的本地接口的广播地址执行ping命令。

linux % ping -b 206.168.112.127
WARNING: pinging broadcast address
PING 206.168.112.127 (206.168.112.127) from 206.168.112.96 : 56(84) bytes of data.
64 bytes from 206.168.112.96: icmp_seq=0 ttl=255 time=241 usec
64 bytes from 206.168.112.40: icmp_seq=0 ttl=255 time=2.566 msec (DUP!)
64 bytes from 206.168.112.118: icmp_seq=0 ttl=255 time=2.973 msec (DUP!)
64 bytes from 206.168.112.14: icmp_seq=0 ttl=255 time=3.089 msec (DUP!)
64 bytes from 206.168.112.126: icmp_seq=0 ttl=255 time=3.200 msec (DUP!)
64 bytes from 206.168.112.71: icmp_seq=0 ttl=255 time=3.311 msec (DUP!)
64 bytes from 206.168.112.31: icmp_seq=0 ttl=64 time=3.541 msec (DUP!)
64 bytes from 206.168.112.7: icmp_seq=0 ttl=255 time=3.636 msec (DUP!)
...

在编写本书时,最引人注目的Unix标准化活动是由Austin公共标准修订组(The Austin Common Standards Revision Group,CSRG)主持的。他们的努力结果是涵盖1 700多个编程接口的约4 000页内容的规范[Josey 2002]。这些规范既具有IEEE POSIX名字,也具有开放团体的技术标准(The Open Group’s Technical Standard)名字。其结果是同一个Unix标准有多个名字来指称:ISO/IEC 9945:2002、IEEE Std 1003.1-2001和单一Unix规范第3版(Single Unix Specification Version 3)都指同一个标准。本书中除了像本节这样需要讨论各种较早期标准各自特性的章节外,我们简单地称这个Unix标准为POSIX规范(The POSIX Specification)。

获取这个统一标准的最简易方法是定购其CD-ROM副本或通过Web免费访问。这两种方法的起始点都是http://www.UNIX.org/version3

25

POSIX(可移植操作系统接口)是Portable Operating System Interface的首字母缩写。它并不是单个标准,而是由电气与电子工程师学会(the Institute for Electrical and Electronics Engineers, Inc.)即IEEE开发的一系列标准。POSIX标准已被国际标准化组织即ISO和国际电工委员会(the International Electrotechnical Commission)即IEC采纳为国际标准(这两个组织合称为ISO/IEC)。下面是POSIX标准的发展简史。

第一个POSIX标准在1986年是称为“IEEE-IX”的试用版。POSIX这个名字是由Richard Stallman建议使用的。

26

第1部分和第2部分就是我们所说的POSIX.1和POSIX.2。

743页中有超过四分之一的篇幅是一个标题为“Rationale and Notes”(理由与注解)的附录。该附录含有历史性信息和某些特性被加入或删除的理由。这些理由通常跟正式标准一样有教益。

这个标准的工作作为P1003.12工作组(后来改名为P1003.1g)起始于20世纪80年代后期。本书称这个标准为POSIX.1g。

开放团体(The Open Group)是由1984年成立的X/Open公司(X/Open Company)和1988年成立的开放软件基金会(Open Software Foundation,OSF)于1996年合并成的组织。它是厂商、工业界最终用户、政府和学术机构共同参加的国际组织。下面是开放团体制定的标准的简要背景。

27

不幸的是,X/Open称它们的网络标准为XNS:X/Open Networking Services。定义Unix 98套接字和XTI的文档的这一版本称为“XNS Issue (XNS第5期)。在网络界,XNS已是Xerox Network Systems体系结构的简称。所以,我们避免使用XNS,而称这个X/Open文档为Unix 98网络API标准。

如本节开头所提,伴随Austin CSRG发布单一Unix规范第3版,POSIX和开放团体都继续发展,达成统一的标准。CSRG促成50多家公司就单一标准达成一致意见,这在Unix发展史上确实是一件划时代之大事。如今大多数Unix系统都符合POSIX.1和POSIX.2的某个版本,不少系统符合单一Unix规范第3版。

历史上多数Unix系统或者源自Berkeley,或者源自System V,不过这些差别在慢慢消失,因为大多数厂商已开始采纳这些标准。然而在系统管理的处理上两者仍然存在较大差别,这个领域目前还没有标准可循。

本书的焦点是单一Unix规范第3版,其中又以套接字API为主。只要可能,我们就使用标准函数。

因特网工程任务攻坚组(Internet Engineering Task Force,IETF)是一个由关心因特网体系结构的发展及其顺利运作的网络设计者、操作员、厂商和研究人员联合组成的开放的国际团体。它向任何感兴趣的个人开放。

因特网标准处理过程在RFC 2026[Bradner 1996]中说明。因特网标准一般处理协议问题而不是编程API,不过仍有两个RFC(RFC 3493[Gilligan et al. 2003]和RFC 3542[Stevens et al. 2003])说明了IPv6的套接字API。它们是信息性的RFC,并不是标准,制定它们的目的是加速部署由多家从事IPv6工作较早的厂商所开发的可移植网络应用程序。尽管标准主体趋于花费很长的时间,其中许多API却已经在单一Unix规范第3版中标准化了。

20世纪90年代中期到未期开始出现向64位体系结构和64位软件发展的趋势。其原因之一是在每个进程内部可以由此使用更长的编址长度(即64位指针),从而可以寻址很大的内存空间(超过232字节)。现有32位Unix系统上共同的编程模型称为ILP32模型,表示整数(I)、长整数(L)和指针(P)都占用32位。64位Unix系统上变得最为流行的模型称为LP64模型,表示只有长整数(L)和指针(P)占用64位。图1-17对这两种模型进行了比较。

图1-17 ILP32和LP64模型保存不同数据类型所占用的位数的比较

28

从编程角度看,LP64模型意味着我们不能假设一个指针能存放在一个整数中。我们还必须考虑LP64模型对现有API的影响。

ANSI C创造了size_t数据类型,它用于作为malloc的唯一参数(待分配的字节数),或者作为readwrite的第三个参数(待读或写的字节数)。在32位系统中size_t是一个32位值,但是在64位系统中它必须是一个64位值,以便发挥更大寻址模型的优势。这意味着64位系统中也许含有一个把size_t定义为unsigned longtypedef指令。联网API存在如下问题:POSIX的某些草案规定,存放套接字地址结构大小的函数参数具有size_t数据类型(如bindconnect的第三个参数)。某些XTI结构也含有数据类型为long的成员(如t_infot_opthdr结构)。如果这些规定不加修改,当Unix系统从ILP32模型转变为LP64模型时,size_tlong都将从32位值变为64位值。这两个例子实际上并不需要使用64位的数据类型:套接字地址结构的长度最多也就几百个字节,给XTI的结构成员使用long数据类型则是个错误。

处理这些情况的办法是使用专门设计的数据类型。套接字API对套接字地址结构的长度使用socklen_t数据类型,XTI则使用t_scalar_tt_uscalar_t数据类型。不把这些值由32位改为64位的理由是易于为那些已在32位系统中编译的应用程序提供在新的64位系统中的二进制代码兼容性。

图1-5展示了一个尽管简单但却完整的TCP客户程序,它从某个指定的服务器读取当前时间和日期;而图1-9则展示了其服务器程序的一个完整版本。这两个例子引入了许多本书其他部分将要扩展的概念和术语。

我们的客户程序与IPv4协议相关,我们于是把它修改成使用IPv6,但这样做却只是给了我们另外一个协议相关的程序。我们将在第11章中开发一些可用来编写协议无关代码的函数,这在因特网开始使用IPv6后会变得非常重要。

纵贯本书,我们将使用1.4节中介绍的包裹函数来缩短代码,同时又保证测试每个函数调用,检查是否返回错误。我们的包裹函数都以一个大写字母开头。

29

单一Unix规范第3版有多个名称,我们简单地称之为POSIX规范。它是两个长期发展的标准团体各自努力的汇合,由Austin CSRG最终团结起来。

对Unix网络支持历史感兴趣的读者可参阅叙述Unix历史的[Salus 1994]和叙述TCP/IP及因特网历史的[Salus 1995]。

1.1 按1.9节未尾的步骤找出你自己的网络拓扑的信息。

1.2 获取本书示例的源代码(见前言),编译并测试图1-5所示的TCP时间获取客户程序。运行这个程序若干次,每次以不同IP地址作为命令行参数。

1.3 把图1-5中的socket的第一参数改为9999。编译并运行这个程序。结果如何?找出对应于所输出出错的errno值。你如何可以找到关于这个错误的更多信息?

1.4 修改图1-5中的while循环,加入一个计数器,累计read返回大于零值的次数。在终止前输出这个计数器值。编译并运行你的新客户程序。

1.5 按下述步骤修改图1-9中的程序。首先,把赋于sin_port的端口号从13改为9999。然后,把write的单一调用改为循环调用,每次写出结果字符串的一个字节。编译修改后的服务器程序并在后台启动执行。接着修改前一道习题中的客户程序(它在终止前输出计数器值),把赋于sin_port的端口号从13改为9999。启动这个客户程序,指定运行修改后的服务器程序的主机的IP地址作为命令行参数。客户程序计数器的输出值是多少?如果可能,在不同主机上运行这个客户与服务器程序。

30

本书英文原文通篇频繁使用client(客户)和server(服务器)这两个术语。实际上它们的具体含义随上下文而变化,有时指静态的源程序或可执行程序(客户程序和服务器程序),有时指动态进程(客户进程和服务器进程),有时指运行进程的主机(客户主机和服务器主机)。在不致引起混淆的前提下,我们简单地称客户进程为客户,称服务器进程为服务器。——译者注

应用(application)这个术语的具体含义随上下文而变化,有时指程序(应用程序),有时指进程(应用进程),有时作为名词性修饰词译为应用。本书有时把同处应用层的客户和服务器对也用应用表示,我们称之为应用系统、网络应用或应用。——译者注

Unix系统中程序(program)和进程(process)是在系统调用exec上衔接的。exec既可以由shell隐式调用(直接输入命令行执行程序属于这种情况),也可以在用户程序中显式调用。显式exec调用执行的程序在本书中称为新程序,以示与exec调用所在程序的区别。exec调用前后两个程序实际上在同一个进程环境下执行,不过往往使用新程序的名字来称呼这个进程。exec调用往往跟在某个fork调用之后,这样新程序将在新的进程环境中执行。客户程序和迭代服务器程序运行时通常只有一个进程,并发服务器程序运行时除主进程外,通常还为每个客户派生一个进程。程序和进程的密切关系使得两者有时相互渗透使用,不易区分。——译者注

internet一词有多种含义。一是网际网(internet),采用TCP/IP协议族通信的任何网络都是网际网,因特网就是一个网际网。二是因特网(Internet),它是一个专用名词,特指从ARPANET发展而来的连接全球各个ISP的大型网际网。三是作为名词性修饰词,这时应根据情况分别译成“因特网”、“网际网”或“网际”。例如, Internet Protocol译成“网际协议”(注意:“Internet Protocol”是“internet protocol”一词名词专用化的结果);Internet Society则译成“因特网学会”。应注意区分因特网和网际网这两个概念:因特网只有一个,为了确保其中任何一个节点(主机或路由器)都能寻址到,其寻址规则和地址分配方案是全球统一的;不属于因特网的网际网却可以为其中的节点任意分配地址,譬如说把因特网中的多播地址(224.0.0.0/4)分配用于单播目的也没有问题,因为地址属性(单播、多播、广播、回馈、私用等)是额外配置到TCP/IP协议族上的,并非TCP/IP协议族的本质特征,尽管实际上TCP/IP的各个实现几乎一律采用因特网的寻址规则。虽然国内权威机构已经为“Internet”一词正过中文名(因特网),许多文献仍然沿用“互联网”这个不确切的名称。互联网的说法是相对内联网(intranet)而言的,后者特指使用因特网私用地址寻址各个节点的网际网,因而只是比较特殊的网际网。——译者注

严格地说,C语言中用#define伪命令定义的对象称为常数,用const限定词定义并初始化的对象称为常量(相对于变量而言)。常数的值在编译时确定,常量的值则在运行时初始化后确定(不过此后只能作为右值使用)。本书绝大多数恒定值是用#define定义的常数。不过“常数”这一称谓容易让人狭义地理解成仅仅是数而已,因此本书统一使用“常值”指代其值恒定不变的对象。——译者注

socket一词译者认为译成“套接口”更为准确,其理由如下。首先,作为网络编程API之一的套接口(sockets,注意这种用法总是采用复数形式,如sockets API、sockets library等)跟XTI一样,是应用层到传输层或其他协议层的访问接口。其次,具体使用的套接口是与Unix管道的某一端类似的东西,我们既可以往这个“口”写数据,也可以从这个“口”读数据。最后,套接口函数使用套接口描述字(discriptor)访问具体的套接口,如果把套接口描述字的简称sockfd译成“套接字”倒比较合适。从这个意义上看,一个套接口可对应多个套接字,因为Unix的描述字既可以复制,也可以继承;反过来,一个套接字对应且只对应一个套接口。但是,鉴于现在socket广泛被接受的译法是“套接字”,所以本书亦采用了“套接字”的译法。相应地,descriptor也采用了“描述符”的译法,而未坚持译为“描述字”。——编者注

为求简洁明确,本书以后尽量采用直接把函数名或C语言关键词用作动词的译法。例如,本句的这种译法是“我们read服务器的应答,并fputs结果。”;又如:“如果connect成功,那就break出循环。”的意思是:“如果connect函数调用成功(表示连接成功),那就执行C语言的break语句跳出循环。”

计算机网络各层对等实体间交换的单位信息称为协议数据单元(protocol data unit,PDU),分节(segment)就是对应于TCP传输层的PDU。按照协议与服务之间的关系,除了最低层(物理层)外,每层的PDU通过由紧邻下层提供给本层的服务接口,作为下层的服务数据单元(service data unit,SDU)传递给下层,并由下层间接完成本层的PDU交换。如果本层的PDU大小超过紧邻下层的最大SDU限制,那么本层还要事先把PDU划分成若干个合适的片段让下层分开载送,再在相反方向把这些片段重组成PDU。同一层内SDU作为PDU的净荷(payload)字段出现,因此可以说上层PDU由本层PDU(通过其SDU字段)承载。每层的PDU除用于承载紧邻上层的PDU(即承载数据)外,也用于承载本层协议内部通信所需的控制信息。由于本书涉及PDU种类较多,为避免混淆,我们在本章末汇总简要说明。
应用层实体(如客户或服务器进程)间交换的PDU称为应用数据(application data),其中在TCP应用进程之间交换的是没有长度限制的单个双向字节流,在UDP应用进程之间交换的是其长度不超过UDP发送缓冲区大小的单个记录(record),在SCTP应用进程之间交换的是没有总长度限制的单个或多个双向记录流。传输层实体(例如对应某个端口的传输层协议代码的一次运行)间交换的PDU称为消息(message),其中TCP的PDU特称为分节(segment)。消息或分节的长度是有限的。在TCP传输层中,发送端TCP把来自应用进程的字节流数据(即由应用进程通过一次次输出操作写出到发送端TCP套接字中的数据)按顺序经分割后封装在各个分节中传送给接收端TCP,其中每个分节所封装的数据既可能是发送端应用进程单次输出操作的结果,也可能是连续数次输出操作的结果,而且每个分节所封装的单次输出操作的结果或者首尾两次输出操作的结果既可能是完整的,也可能是不完整的,具体取决于可在连接建立阶段由对端通告的最大分节大小(maximum segment size,MSS)以及外出接口的最大传输单元(maximum transmission unit,MTU)或外出路径的路径MTU(如果网络层具有路径MTU发现功能,如IPv6)。分节除了用于承载应用数据外,也用于建立连接(SYN分节)、终止连接(FIN分节)、中止连接(RST分节)、确认数据接收(ACK分节)、刷送待发数据(PSH分节)和携带紧急数据指针(URG分节),而且这些功能(包括承载数据)可以灵活组合。UDP传输层相当简单,发送端UDP就把来自应用进程的单个记录整个封装在UDP消息中传送给接收端UDP。SCTP引入了称为块(chunk)的数据单元,SCTP消息就由一个公共首部加上一个或多个块构成:公共首部类似UDP消息的首部,仅仅给出源目的端口号和整个SCTP消息的校验和;块则既可以承载数据(称为DATA块),也可以承载控制信息(计有SACK块、INIT块、INIT ACK块、COOKIE ECHO块、COOKIE ACK块、SHUTDOWN块、SHUTDOWN ACK块、SHUTDOWN COMPLETE块、ABORT块、ERROR块、HEARTBEAT块和HEARTBEAT ACK块,总称为控制块)。发送端SCTP把来自应用进程的(一个或多个)记录流数据按照流内顺序和记录边界封装在各个DATA块中,并在DATA块首部记上各自的流ID。一个记录通常对应一个DATA块;对于过长的记录,发送端SCTP既可以像UDP那样拒绝发送,也可以把它们拆分到多个DATA块中以便发送,接收端SCTP收取后把它们组合成单个记录上传。作为传输层PDU的SCTP消息既可以只包含单个块(DATA块或控制块),也可以在接口MTU或路径MTU的限制下包含多个块(称为块的捆绑,控制块在前,DATA块在后),不过INIT块、INIT ACK块和SHUTDOWN COMPLETE块不能跟任何其他块捆绑。SCTP收发两端均独立处理捆绑在同一个消息中的各个块,鉴于此,我们可以直接把块作为传输层PDU看待,本书也往往这么使用。
网络层实体间交换的PDU称为IP数据报(IP datagram),其长度有限:IPv4数据报最大65 535字节,IPv6数据报最大65 575字节。发送端IP把来自传输层的消息(或TCP分节)整个封装在IP数据报中传送。链路层实体间交换的PDU称为帧(frame),其长度取决于具体的接口。IP数据报由IP首部和所承载的传输层数据(即网络层的SDU)构成。过长的IP数据报无法封装在单个帧中,需要先对其SDU进行分片(fragmentation),再把分成的各个片段(fragment)冠以新的IP首部封装到多个帧中。在一个IP数据报从源端到目的端的传送过程中,分片操作既可能发生在源端,也可能发生在途中,其逆操作即重组(reassembly)一般只发生在目的端;SCTP为了传送过长的记录采取了类似的分片和重组措施。TCP/IP协议族为提高效率会尽可能避免IP的分片/重组操作:TCP根据MSS和MTU限定每个分节的大小以及SCTP根据MTU分片/重组过长记录都是这个目的(SCTP的块捆绑则是为了在避免IP分片/重组操作的前提下提高块传输效率);另外,IPv6禁止在途中的分片操作(基于其路径MTU发现功能),IPv4也尽量避免这种操作。不论是否分片,都由IP作为链路层的SDU传入链路层,并由链路层封装在帧中的数据称为分组(packet,俗称包)。可见一个分组既可能是一个完整的IP数据报,也可能是某个IP数据报的SDU的一个片段被冠以新的IP首部后的结果。另外,本书中讨论的MSS是应用层(TCP)与传输层之间的接口属性,MTU则是网络层和链路层之间的接口属性。
上述讨论参见RFC 1122、RFC 793、RFC 768、RFC 3286、RFC 2960和本书2.11节、7.9节。另外需注意的是,SCTP目前只是处于提案标准(proposed standard)阶段,尚未进入能够被多数厂商采纳并实现的草案标准(draft standard)阶段,更没有像TCP和UDP那样历经考验而成为因特网标准(分配STD号)。——译者注

手册页面(manual page或man page)是所有Unix系统都提供的使用man命令查看到的有关命令、函数和文件等的帮助信息。某个条目的手册页面就是以该条目为命令行参数执行man的输出。——译者注

这里被认可标准(approved standard)意思是成为正式标准前的特定阶段。——译者注


本章提供本书示例所用TCP/IP协议的概貌。我们的目的是从网络编程角度提供足够的细节以理解如何使用这些协议,同时提供有关这些协议的实际设计、实现及历史的具体描述的参考点。

本章的焦点是传输层,包括TCP、UDP和SCTP(Stream Control Transmission Protocol,流控制传输协议)。绝大多数客户/服务器网络应用使用TCP或UDP。SCTP是一个较新的协议,最初设计用于跨因特网传输电话信令。这些传输协议都转而使用网络层协议IP:或是IPv4,或是IPv6。尽管可以绕过传输层直接使用IPv4或IPv6,但这种技术(往往称为原始套接字)却极少使用。因此,我们把IPv4和IPv6以及ICMPv4和ICMPv6的详细描述安排在附录A中。

UDP是一个简单的、不可靠的数据报协议,而TCP是一个复杂、可靠的字节流协议。SCTP与TCP类似之处在于它也是一个可靠的传输协议,但它还提供消息边界、传输级别多宿(multihoming)支持以及将头端阻塞(head-of-line blocking)减少到最小的一种方法。我们必须了解由这些传输层协议提供给应用进程的服务,这样才能弄清这些协议处理什么,应用进程中又需要处理什么。

TCP的某些特性一旦理解,就很容易编写健壮的客户和服务器程序,也很容易使用诸如netstat等普遍可用的工具来调试客户和服务器程序。本章将阐述以下相关主题:TCP的三路握手、TCP的连接终止序列和TCP的TIME_WAIT状态,SCTP的四路握手和SCTP的连接终止,加上由套接字层提供的TCP、UDP和SCTP缓冲机制,等等。

31

虽然协议族被称为“TCP/IP”,但除了TCP和IP这两个主要协议外,还有许多其他成员。图2-1展示了这些协议的概况。

图2-1 TCP/IP协议概况

图2-1中同时展示了IPv4和IPv6。从右向左查看该图,最右边的5个网络应用在使用IPv6;我们将在第3章中随sockaddr_in6结构讲解AF_INET6常值。随后的6个网络应用使用IPv4。

最左边名为tcpdump的网络应用或者使用BSD分组过滤器(BSD packet filter,BPF),或者使用数据链路提供者接口(datalink provider interface,DLPI)直接与数据链路进行通信。处于其右边所有9个应用下面的虚线标记为API,它通常是套接字或XTI。访问BPF或DLPI的接口不使用套接字或XTI。

这种情况存在一个例外:Linux使用一种称为SOCK_PACKET的特殊套接字类型提供对于数据链路的访问。我们将在第28章中详细讲述这个例外。

图2-1中还标明traceroute程序使用两种套接字:IP套接字用于访问IP,ICMP套接字用于访问ICMP。在第28章中,我们将开发pingtraceroute这两个应用的IPv4和IPv6版本。

下面我们讲解一下图2-1中的每一个协议框。

IPv4 网际协议版本 4(Internet Protocol version 4)。IPv4(通常称之为IP)自20世纪80年代早期以来一直是网际协议族的主力协议。它使用32位地址(见A.4节)。IPv4给TCP、UDP、SCTP、ICMP和IGMP提供分组递送服务。

Pv6 网际协议版本 6(Internet Protocol version 6)。IPv6是在20世纪90年代中期作为IPv4的一个替代品设计的。其主要变化是使用128位更大地址(见A.5节)以应对20世纪90年代因特网的爆发性增长。IPv6给TCP、UDP、SCTP和ICMPv6提供分组递送服务。

当无需区别IPv4和IPv6时,我们经常把“IP”一词作为形容词使用,如IP、IP地址等。

TCP 传输控制协议(Transmission Control Protocol)。TCP是一个面向连接的协议,为用户进程提供可靠的全双工字节流。TCP套接字是一种流套接字(stream socket)。TCP关心确认、超时和重传之类的细节。大多数因特网应用程序使用TCP。注意,TCP既可以使用IPv4,也可以使用IPv6。

UDP 用户数据报协议(User Datagram Protocol)。UDP是一个无连接协议。UDP套接字是一种数据报套接字(datagram socket)。UDP数据报不能保证最终到达它们的目的地。与TCP一样,UDP既可以使用IPv4,也可以使用IPv6。

SCTP 流控制传输协议(Stream Control Transmission Protocol)。SCTP是一个提供可靠全双工关联的面向连接的协议,我们使用“关联”一词来指称SCTP中的连接,因为SCTP是多宿的,从而每个关联的两端均涉及一组IP地址和一个端口号。SCTP提供消息服务,也就是维护来自应用层的记录边界。与TCP和UDP一样,SCTP既可以使用IPv4,也可以使用IPv6,而且能够在同一个关联中同时使用它们。

ICMP 网际控制消息协议(Internet Control Message Protocol)。ICMP处理在路由器和主机之间流通的错误和控制消息。这些消息通常由TCP/IP网络支持软件本身(而不是用户进程)产生和处理,不过图中展示的pingtraceroute程序同样使用ICMP。有时我们称这个协议为ICMPv4,以便与ICMPv6相区别。

IGMP 网际组管理协议(Internet Group Management Protocol)。IGMP用于多播(见第21章),它在IPv4中是可选的。

32~33

ARP 地址解析协议(Address Resolution Protocol)。ARP把一个IPv4地址映射成一个硬件地址(如以太网地址)。ARP通常用于诸如以太网、令牌环网和FDDI等广播网络,在点到点网络上并不需要。

RARP 反向地址解析协议(Reverse Address Resolution Protocol)。RARP把一个硬件地址映射成一个IPv4地址。它有时用于无盘节点的引导。

ICMPv6 网际控制消息协议版本6(Internet Control Message Protocol version 6)。ICMPv6综合了ICMPv4、IGMP和ARP的功能。

BPF BSD分组过滤器(BSD packet filter)。该接口提供对于数据链路层的访问能力,通常可以在源自Berkeley的内核中找到。

DLPI 数据链路提供者接口(datalink provider interface)。该接口也提供对于数据链路层的访问能力,通常随SVR4内核提供。

所有网际协议由一个或多个称为请求评注(Request for Comments,RFC)的文档定义,这些RFC就是它们的正式规范。习题2.1的答案说明如何获得这些RFC。

我们使用术语“IPv4/IPv6主机”或“双栈主机”表示同时支持IPv4和IPv6的主机。

TCP/IP协议的其他细节参见TCPv1。TCP/IP在4.4BSD上的实现参见TCPv2。

UDP是一个简单的传输层协议,在RFC 768[Postel 1980]中有详细说明。应用进程往一个UDP套接字写入一个消息,该消息随后被封装(encapsulating)到一个UDP数据报,该UDP数据报进而又被封装到一个IP数据报,然后发送到目的地。UDP不保证UDP数据报会到达其最终目的地,不保证各个数据报的先后顺序跨网络后保持不变,也不保证每个数据报只到达一次。

我们使用UDP进行网络编程所遇到的问题是它缺乏可靠性。如果一个数据报到达了其最终目的地,但是校验和检测发现有错误,或者该数据报在网络传输途中被丢弃了,它就无法被投递给UDP套接字,也不会被源端自动重传。如果想要确保一个数据报到达其目的地,可以往应用程序中添置一大堆的特性:来自对端的确认、本端的超时与重传等。

每个UDP数据报都有一个长度。如果一个数据报正确地到达其目的地,那么该数据报的长度将随数据一道传递给接收端应用进程。我们已经提到过TCP是一个字节流(byte-stream)协议,没有任何记录边界(见1.2节),这一点不同于UDP。

我们也说UDP提供无连接的(connectionless)服务,因为UDP客户与服务器之间不必存在任何长期的关系。举例来说,一个UDP客户可以创建一个套接字并发送一个数据报给一个给定的服务器,然后立即用同一个套接字发送另一个数据报给另一个服务器。同样地,一个UDP服务器可以用同一个UDP套接字从若干个不同的客户接收数据报,每个客户一个数据报。

34

由TCP向应用进程提供的服务不同于由UDP提供的服务。TCP在RFC 793[Poste1 ]中有详细说明,然后由RFC 1323[Jacobson, Braden, and Borman 1992]、RFC 2581[Allman, Paxson, and Stevens 1999]、RFC 2988[Paxson and Allman 2000]和RFC 3390[Allman, Floyd, and Partridge 2002]加以更新。首先,TCP提供客户与服务器之间的连接(connection)。TCP客户先与某个给定服务器建立一个连接,再跨该连接与那个服务器交换数据,然后终止这个连接。

其次,TCP还提供了可靠性(reliability)。当TCP向另一端发送数据时,它要求对端返回一个确认。如果没有收到确认,TCP就自动重传数据并等待更长时间。在数次重传失败后,TCP才放弃,如此在尝试发送数据上所花的总时间一般为4~10分钟(依赖于具体实现)。

注意,TCP并不保证数据一定会被对方端点接收,因为这是不可能做到的。如果有可能,TCP就把数据递送到对方端点,否则就(通过放弃重传并中断连接这一手段)通知用户。这么说来,TCP也不能被描述成是100%可靠的协议,它提供的是数据的可靠递送或故障的可靠通知。

TCP含有用于动态估算客户和服务器之间的往返时间(round-trip time,RTT)的算法,以便它知道等待一个确认需要多少时间。举例来说,RTT在一个局域网上大约是几毫秒,跨越一个广域网则可能是数秒钟。另外,因为RTT受网络流通各种变化因素影响,TCP还持续估算一个给定连接的RTT。

TCP通过给其中每个字节关联一个序列号对所发送的数据进行排序(sequencing)。举例来说,假设一个应用写2048字节到一个TCP套接字,导致TCP发送2个分节:第一个分节所含数据的序列号为1~1024,第二个分节所含数据的序列号为1025~2048。(分节是TCP传递给IP的数据单元。)如果这些分节非顺序到达,接收端TCP将先根据它们的序列号重新排序,再把结果数据传递给接收应用。如果接收端TCP接收到来自对端的重复数据(譬如说对端认为一个分节已丢失并因此重传,而这个分节并没有真正丢失,只是网络通信过于拥挤),它可以(根据序列号)判定数据是重复的,从而丢弃重复数据。

UDP不提供可靠性。UDP本身不提供确认、序列号、RTT估算、超时和重传等机制。如果一个UDP数据报在网络中被复制,两份副本就可能都递送到接收端的主机。同样地,如果一个UDP客户发送两个数据报到同一个目的地,它们可能被网络重新排序,颠倒顺序后到达目的地。UDP应用必须处理所有这些情况,在22.5节中我们将展示如何处理。

35

再次,TCP提供流量控制(flow control)。TCP总是告知对端在任何时刻它一次能够从对端接收多少字节的数据,这称为通告窗口(advertised window)。在任何时刻,该窗口指出接收缓冲区中当前可用的空间量,从而确保发送端发送的数据不会使接收缓冲区溢出。该窗口时刻动态变化:当接收到来自发送端的数据时,窗口大小就减小,但是当接收端应用从缓冲区中读取数据时,窗口大小就增大。通告窗口大小减小到0是有可能的:当TCP对应某个套接字的接收缓冲区已满,导致它必须等待应用从该缓冲区读取数据时,方能从对端再接收数据。

UDP不提供流量控制。如我们将在8.13节所示,让较快的UDP发送端以一个UDP接收端难以跟上的速率发送数据报是非常容易的。

最后,TCP连接是全双工的(full-duplex)。这意味着在一个给定的连接上应用可以在任何时刻在进出两个方向上既发送数据又接收数据。因此,TCP必须为每个数据流方向跟踪诸如序列号和通告窗口大小等状态信息。建立一个全双工连接后,需要的话可以把它转换成一个单工连接(见6.6节)。

UDP可以是全双工的。

SCTP提供的服务与UDP和TCP提供的类似。SCTP在RFC 2960[Stewart et al. 2000]中详细说明,并由RFC 3309[Stone, Stewart, and Otis 2002]加以更新。RFC 3286[Ong and Yoakum 2002]给出了SCTP的简要介绍。SCTP在客户和服务器之间提供关联(association),并像TCP那样给应用提供可靠性、排序、流量控制以及全双工的数据传送。SCTP中使用“关联”一词取代“连接”是为了避免这样的内涵:一个连接只涉及两个IP地址之间的通信。一个关联指代两个系统之间的一次通信,它可能因为SCTP支持多宿而涉及不止两个地址。

与TCP不同的是,SCTP是面向消息的(message-oriented)。它提供各个记录的按序递送服务。与UDP一样,由发送端写入的每条记录的长度随数据一道传递给接收端应用。

SCTP能够在所连接的端点之间提供多个流,每个流各自可靠地按序递送消息。一个流上某个消息的丢失不会阻塞同一关联其他流上消息的投递。这种做法与TCP正好相反,就TCP而言,在单一字节流中任何位置的字节丢失都将阻塞该连接上其后所有数据的递送,直到该丢失被修复为止。

SCTP还提供多宿特性,使得单个SCTP端点能够支持多个IP地址。该特性可以增强应对网络故障的健壮性。一个端点可能有多个冗余的网络连接,每个网络又可能有各自接入因特网基础设施的连接。当该端点与另一个端点建立一个关联后,如果它的某个网络或某个跨越因特网的通路发生故障,SCTP就可以通过切换到使用已与该关联相关的另一个地址来规避所发生的故障。

36

类似的健壮性在路由协议的辅助下也可以从TCP中获得。举例来说,由iBGP实现的同一域内的BGP连接往往把赋予路由器内某个虚拟接口的多个地址用作TCP连接的端点。该域的路由协议确保两个路由器之间只要存在一条路由,该路由就会被用上,从而保证这两个路由器之间的BGP连接可用;要是使用属于某个物理接口的地址来建立BGP连接,该物理接口又变得不工作了,这一点就不可能做到。SCTP的多宿特性允许主机(而不仅仅是路由器)也多宿,而且允许多宿跨越不同的服务供应商发生,这些基于路由的TCP多宿方法都无法做到。

为帮助大家理解connectacceptclose这3个函数并使用netstat程序调试TCP应用,我们必须了解TCP连接如何建立和终止,并掌握TCP的状态转换图。

建立一个TCP连接时会发生下述情形。

(1)服务器必须准备好接受外来的连接。这通常通过调用socketbindlisten这3个函数来完成,我们称之为被动打开(passive open)。

(2)客户通过调用connect发起主动打开(active open)。这导致客户TCP发送一个SYN(同步)分节,它告诉服务器客户将在(待建立的)连接中发送的数据的初始序列号。通常SYN分节不携带数据,其所在IP数据报只含有一个IP首部、一个TCP首部及可能有的TCP选项(我们稍后讲解)。

(3)服务器必须确认(ACK)客户的SYN,同时自己也得发送一个SYN分节,它含有服务器将在同一连接中发送的数据的初始序列号。服务器在单个分节中发送SYN和对客户SYN的ACK(确认)。

(4)客户必须确认服务器的SYN。

这种交换至少需要3个分组,因此称之为TCP的三路握手(three-way handshake)。图2-2展示了所交换的3个分节。

图2-2 TCP的三路握手

37

图2-2给出的客户的初始序列号为J,服务器的初始序列号为K。ACK中的确认号是发送这个ACK的一端所期待的下一个序列号。因为SYN占据一个字节的序列号空间,所以每一个SYN的ACK中的确认号就是该SYN的初始序列号加1。类似地,每一个FIN(表示结束)的ACK中的确认号为该FIN的序列号加1。

建立TCP连接就好比一个电话系统[Nemeth 1997]。socket函数等同于有电话可用。bind函数是在告诉别人你的电话号码,这样他们可以呼叫你。listen函数是打开电话振铃,这样当有一个外来呼叫到达时,你就可以听到。connect函数要求我们知道对方的电话号码并拨打它。accept函数发生在被呼叫的人应答电话之时。由accept返回客户的标识(即客户的IP地址和端口号)类似于让电话机的呼叫者ID功能部件显示呼叫者的电话号码。然而两者的不同之处在于accept只在连接建立之后返回客户的标识,而呼叫者ID功能部件却在我们选择应答或不应答电话之前显示呼叫者的电话号码。如果使用域名系统DNS(见第11章),它就提供了一种类似于电话簿的服务。getaddrinfo类似于在电话簿中查找某个人的电话号码,getnameinfo则类似于有一本按照电话号码而不是按照用户名排序的电话簿。

每一个SYN可以含有多个TCP选项。下面是常用的TCP选项。

为提供与不支持这个选项的较早实现间的互操作性,需应用如下规则。TCP可以作为主动打开的部分内容随它的SYN发送该选项,但是只在对端也随它的SYN发送该选项的前提下,它才能扩大自己窗口的规模。类似地,服务器的TCP只有接收到随客户的SYN到达的该选项时,才能发送该选项。本逻辑假定实现忽略它们不理解的选项,如此忽略是必需的要求,也已普遍满足,但无法保证所有实现都满足此要求。

38

TCP的大多数实现都支持这些常用选项。后两个选项有时称为“RFC 1323选项”,因为它们是在RFC 1323[Jacobson, Braden, and Borman 1992]中说明的。既然高带宽或长延迟的网络被称为“长胖管道”(long fat pipe),这两个选项也称为“长胖管道选项”。TCPv1的第24章对这些选项有详细的叙述。

TCP建立一个连接需3个分节,终止一个连接则需4个分节。

(1)某个应用进程首先调用close,我们称该端执行主动关闭(active close)。该端的TCP于是发送一个FIN分节,表示数据发送完毕。

(2)接收到这个FIN的对端执行被动关闭(passive close)。这个FIN由TCP确认。它的接收也作为一个文件结束符(end-of-file)传递给接收端应用进程(放在已排队等候该应用进程接收的任何其他数据之后),因为FIN的接收意味着接收端应用进程在相应连接上再无额外数据可接收。

(3)一段时间后,接收到这个文件结束符的应用进程将调用close关闭它的套接字。这导致它的TCP也发送一个FIN。

(4)接收这个最终FIN的原发送端TCP(即执行主动关闭的那一端)确认这个FIN。

既然每个方向都需要一个FIN和一个ACK,因此通常需要4个分节。我们使用限定词“通常”是因为:某些情形下步骤1的FIN随数据一起发送;另外,步骤2和步骤3发送的分节都出自执行被动关闭那一端,有可能被合并成一个分节。图2-3展示了这些分组。

图2-3 TCP连接关闭时的分组交换

类似SYN,一个FIN也占据1个字节的序列号空间。因此,每个FIN的ACK确认号就是这个FIN的序列号加1。

在步骤2与步骤3之间,从执行被动关闭一端到执行主动关闭一端流动数据是可能的。这称为半关闭(half-close),我们将在6.6节随shutdown函数再详细介绍。

当套接字被关闭时,其所在端TCP各自发送了一个FIN。我们在图中指出,这是由应用进程调用close而发生的,不过需认识到,当一个Unix进程无论自愿地(调用exit或从main函数返回)还是非自愿地(收到一个终止本进程的信号)终止时,所有打开的描述符都被关闭,这也导致仍然打开的任何TCP连接上也发出一个FIN。

图2-3展示了客户执行主动关闭的情形,不过我们指出,无论是客户还是服务器,任何一端都可以执行主动关闭。通常情况是客户执行主动关闭,但是某些协议(譬如值得注意的HTTP/1.0)却由服务器执行主动关闭。

TCP涉及连接建立和连接终止的操作可以用状态转换图(state transition diagram)来说明,如图2-4所示。

图2-4 TCP状态转换图

TCP为一个连接定义了11种状态,并且TCP规则规定如何基于当前状态及在该状态下所接收的分节从一个状态转换到另一个状态。举例来说,当某个应用进程在CLOSED状态下执行主动打开时,TCP将发送一个SYN,且新的状态是SYN_SENT。如果这个TCP接着接收到一个带ACK的SYN,它将发送一个ACK,且新的状态是ESTABLISHED。这个最终状态是绝大多数数据传送发生的状态。

自ESTABLISHED状态引出的两个箭头处理连接的终止。如果某个应用进程在接收到一个FIN之前调用close(主动关闭),那就转换到FIN_WAIT_1状态。但如果某个应用进程在ESTABLISHED状态期间接收到一个FIN(被动关闭),那就转换到CLOSE_WAIT状态。

我们用粗实线表示通常的客户状态转换,用粗虚线表示通常的服务器状态转换。图中还注明存在两个我们未曾讨论的转换:一个为同时打开(simultaneous open),发生在两端几乎同时发送SYN并且这两个SYN在网络中交错的情形下,另一个为同时关闭(simultaneous close),发生在两端几乎同时发送FIN的情形下。TCPv1的第18章中有这两种情况的例子和讨论,它们是可能发生的,不过非常罕见。

39~40

展示状态转换图的原因之一是给出11种TCP状态的名称。这些状态可使用netstat显示,它是一个在调试客户/服务器应用时很有用的工具。我们将在第5章中使用netstat去监视状态的变化。

41

图2-5展示一个完整的TCP连接所发生的实际分组交换情况,包括连接建立、数据传送和连接终止3个阶段。图中还展示了每个端点所历经的TCP状态。

图2-5 TCP连接的分组交换

本例中的客户通告一个值为536的MSS(表明该客户只实现了最小重组缓冲区大小),服务器通告一个值为1460的MSS(以太网上IPv4的典型值)。不同方向上MSS值不相同不成问题(见习题2.5)。

一旦建立一个连接,客户就构造一个请求并发送给服务器。这里我们假设该请求适合于单个TCP分节(即请求大小小于服务器通告的值为1460字节的MSS)。服务器处理该请求并发送一个应答,我们假设该应答也适合于单个分节(本例即小于536字节)。图中使用粗箭头表示这两个数据分节。注意,服务器对客户请求的确认是伴随其应答发送的。这种做法称为捎带(piggybacking),它通常在服务器处理请求并产生应答的时间少于200 ms时发生。如果服务器耗用更长时间,譬如说1 s,那么我们将看到先是确认后是应答。(TCP数据流机理在TCPv1的第19章和第20章中详细叙述。)

42

图中随后展示的是终止连接的4个分节。注意,执行主动关闭的那一端(本例子中为客户)进入我们将在下一节中讨论的TIME_WAIT状态。

图2-5中值得注意的是,如果该连接的整个目的仅仅是发送一个单分节的请求和接收一个单分节的应答,那么使用TCP有8个分节的开销。如果改用UDP,那么只需交换两个分组:一个承载请求,一个承载应答。然而从TCP切换到UDP将丧失TCP提供给应用进程的全部可靠性,迫使可靠服务的一大堆细节从传输层(TCP)转移到UDP应用进程。TCP提供的另一个重要特性即拥塞控制也必须由UDP应用进程来处理。尽管如此,我们仍然需要知道许多网络应用是使用UDP构建的,因为它们需要交换的数据量较少,而UDP避免了TCP连接建立和终止所需的开销。

毫无疑问,TCP中有关网络编程最不容易理解的是它的TIME_WAIT状态。在图2-4中我们看到执行主动关闭的那端经历了这个状态。该端点停留在这个状态的持续时间是最长分节生命期(maximum segment lifetime,MSL)的两倍,有时候称之为2MSL。

任何TCP实现都必须为MSL选择一个值。RFC 1122[Braden 1989]的建议值是2分钟,不过源自Berkeley的实现传统上改用30秒这个值。这意味着TIME_WAIT状态的持续时间在1分钟到4分钟之间。MSL是任何IP数据报能够在因特网中存活的最长时间。我们知道这个时间是有限的,因为每个数据报含有一个称为跳限(hop limit)的8位字段(见图A-1中IPv4的TTL字段和图A-2中IPv6的跳限字段),它的最大值为255。尽管这是一个跳数限制而不是真正的时间限制,我们仍然假设:具有最大跳限(255)的分组在网络中存在的时间不可能超过MSL秒。

分组在网络中“迷途”通常是路由异常的结果。某个路由器崩溃或某两个路由器之间的某个链路断开时,路由协议需花数秒钟到数分钟的时间才能稳定并找出另一条通路。在这段时间内有可能发生路由循环(路由器A把分组发送给路由器B,而B再把它们发送回A),我们关心的分组可能就此陷入这样的循环。假设迷途的分组是一个TCP分节,在它迷途期间,发送端TCP超时并重传该分组,而重传的分组却通过某条候选路径到达最终目的地。然而不久后(自迷途的分组开始其旅程起最多MSL秒以内)路由循环修复,早先迷失在这个循环中的分组最终也被送到目的地。这个原来的分组称为迷途的重复分组(lost duplicate)或漫游的重复分组(wandering duplicate)。TCP必须正确处理这些重复的分组。

43

TIME_WAIT状态有两个存在的理由:

(1)可靠地实现TCP全双工连接的终止;

(2)允许老的重复分节在网络中消逝。

第一个理由可以通过查看图2-5并假设最终的ACK丢失了来解释。服务器将重新发送它的最终那个FIN,因此客户必须维护状态信息,以允许它重新发送最终那个ACK。要是客户不维护状态信息,它将响应以一个RST(另外一种类型的TCP分节),该分节将被服务器解释成一个错误。如果TCP打算执行所有必要的工作以彻底终止某个连接上两个方向的数据流(即全双工关闭),那么它必须正确处理连接终止序列4个分节中任何一个分节丢失的情况。本例子也说明了为什么执行主动关闭的那一端是处于TIME_WAIT状态的那一端:因为可能不得不重传最终那个ACK的就是那一端。

为理解存在TIME_WAIT状态的第二个理由,我们假设在12.106.32.254的1500端口和206.168.112.219的21端口之间有一个TCP连接。我们关闭这个连接,过一段时间后在相同的IP地址和端口之间建立另一个连接。后一个连接称为前一个连接的化身(incarnation),因为它们的IP地址和端口号都相同。TCP必须防止来自某个连接的老的重复分组在该连接已终止后再现,从而被误解成属于同一连接的某个新的化身。为做到这一点,TCP将不给处于TIME_WAIT状态的连接发起新的化身。既然TIME_WAIT状态的持续时间是MSL的2倍,这就足以让某个方向上的分组最多存活MSL秒即被丢弃,另一个方向上的应答最多存活MSL秒也被丢弃。通过实施这个规则,我们就能保证每成功建立一个TCP连接时,来自该连接先前化身的老的重复分组都已在网络中消逝了。

这个规则存在一个例外:如果到达的SYN的序列号大于前一化身的结束序列号,源自Berkeley的实现将给当前处于TIME_WAIT状态的连接启动新的化身。TCPv2第958~959页对这种情况有详细的叙述。它要求服务器执行主动关闭,因为接收下一个SYN的那一端必须处于TIME_WAIT状态。rsh命令具备这种能力。RFC 1185[Jacobson, Braden, and Zhang 1990]讲述了有关这种情形的一些陷阱。

与TCP一样,SCTP也是面向连接的,因而也有关联的建立与终止的握手过程。不过SCTP的握手过程不同于TCP,我们在此加以说明。

44

建立一个SCTP关联的时候会发生下述情形(类似于TCP)。

(1)服务器必须准备好接受外来的关联。这通常通过调用socketbindlisten这3个函数来完成,称为被动打开

(2)客户通过调用connect或者发送一个隐式打开该关联的消息进行主动打开。这使得客户SCTP发送一个INIT消息(初始化),该消息告诉服务器客户的IP地址清单、初始序列号、用于标识本关联中所有分组的起始标记、客户请求的外出流的数目以及客户能够支持的外来流的数目。

(3)服务器以一个INIT ACK消息确认客户的INIT消息,其中含有服务器的IP地址清单、初始序列号、起始标记、服务器请求的外出流的数目、服务器能够支持的外来流的数目以及一个状态cookie。状态cookie包含服务器用于确信本关联有效所需的所有状态,它是数字化签名过的,以确保其有效性。

(4)客户以一个COOKIE ECHO消息回射服务器的状态cookie。除COOKIE ECHO外,该消息可能在同一个分组中还捆绑了用户数据。

(5)服务器以一个COOKIE ACK消息确认客户回射的cookie是正确的,本关联于是建立。该消息也可能在同一个分组中还捆绑了用户数据。

以上交换过程至少需要4个分组,因此称之为SCTP的四路握手(four-way handshake)。图2-6展示了这4个分节。

图2-6 SCTP的四路握手

45

SCTP的四路握手在很多方面类似于TCP的三路握手,差别主要在于作为SCTP整体一部分的cookie的生成。INIT(随其众多参数一道)承载一个验证标记Ta和一个初始序列号J。在关联的有效期内,验证标记Ta必须在对端发送的每个分组中出现。初始序列号J用作承载用户数据的DATA块的起始序列号。对端也在INIT ACK中承载一个验证标记Tz,在关联的有效期内,验证标记Tz也必须在其发送的每个分组中出现。除了验证标记Tz和初始序列号K外,INIT的接收端还在作为响应的INIT ACK中提供一个cookie C。该cookie包含设置本SCTP关联所需的所有状态,这样服务器的SCTP栈就不必保存所关联客户的有关信息。SCTP关联设置的细节参见[Stewart and Xie 2001]的第4章。

四路握手过程结束时,两端各自选择一个主目的地址(primary destination address)。当不存在网络故障时,主目的地址将用作数据要发送到的默认目的地。

在SCTP中使用四路握手是为了避免一种将在4.5节讨论的拒绝服务攻击。

SCTP使用cookie的四路握手定形了一种防护这种攻击的方法。TCP的许多实现也使用类似的方法。两者的主要差别在于,TCP中cookie状态必须编码到只有32位长的初始序列号中。SCTP为此提供了一个任意长度的字段,并且要求实施基于加密的安全性以防护攻击。

SCTP不像TCP那样允许“半关闭”的关联。当一端关闭某个关联时,另一端必须停止发送新的数据。关联关闭请求的接收端发送完已经排队的数据(如果有的话)后,完成关联的关闭。图2-7展示了这一交换过程。

图2-7 SCTP关联关闭时的分组交换

SCTP没有类似于TCP的TIME_WAIT状态,因为SCTP使用了验证标记。所有后续块都在捆绑它们的SCTP分组的公共首部标记了初始的INIT块和INIT ACK块中作为起始标记交换的验证标记;由来自旧连接的块通过所在SCTP分组的公共首部间接携带的验证标记对于新连接来说是不正确的。因此,SCTP通过放置验证标记值就避免了TCP在TIME_WAIT状态保持整个连接的做法。

46

SCTP涉及关联建立和关联终止的操作可以用状态转换图(state transition diagram)来说明,如图2-8所示。

图2-8 SCTP状态转换图

与图2-4一样,本状态机中从一个状态到另一个状态的转换由SCTP规则基于当前状态及在该状态下所接收的块规定。举例来说,当某个应用进程在CLOSED状态下执行主动打开时,SCTP将发送一个INIT,且新的状态是COOKIE-WAIT。如果这个SCTP接着接收到一个INIT ACK,它将发送一个COOKIE ECHO,且新的状态是COOKIE-ECHOED。如果该SCTP随后接收到一个COOKIE ACK,它将转换成ESTABLISHED状态。这个最终状态是绝大多数数据传送发生点的状态,尽管DATA块也可以由COOKIE ECHO块或COOKIE ACK块所在消息捆绑捎带。

47~48

从ESTABLISHED状态引出的两个箭头处理关联的终止。如果某个应用进程在接收到一个SHUTDOWN之前调用close(主动关闭),那就转换到SHUTDOWN-PENDING状态。否则,如果某个应用进程在ESTABLISHED状态期间接收到一个SHUTDOWN(被动关闭),那就转换到SHUTDOWN-RECEIVED状态。

图2-9展示一个作为样例的SCTP关联所发生的实际分组交换情况,包括关联建立、数据传送和关联终止3个阶段。图中还展示了每个端点所历经的SCTP状态。

图2-9 SCTP关联中的分组交换

本例中,客户在COOKIE ECHO块所在分组中捎带了它的第一个DATA块,服务器则在作为应答的COOKIE ACK块所在分组中捎带了数据。一般而言,当网络应用采用一到多接口式样时(我们将在9.2节中讨论一到一和一到多这两种接口式样),COOKIE ECHO通常捎带一个或多个DATA块。

SCTP分组中信息的单位称为块(chunk)。块是自描述的,包含一个块类型、若干个块标记和一个块长度。这样做方便了多个块的绑缚,只要把它们简单地组合到一个SCTP外出消息中([Stewart and Xie 2001]的第5章给出了块捆绑和常规数据传输过程的细节)。

49

SCTP使用参数和块方便增设可选特性。新的特性通过添加这两个条目之一加以定义,并允许通常的SCTP处理规则汇报未知的参数和未知的块。参数类型字段和块类型字段的高两位指明SCTP接收端该如何处置未知的参数或未知的块([Stewart and Xie 2001]的3.1节给出了更多的细节)。

当前如下两个对SCTP的扩展正在开发中。

(1)动态地址扩展,允许协作的SCTP端点从已有的某个关联中动态增删IP地址。

(2)不完全可靠性扩展,允许协作的SCTP端点在应用进程的指导下限制数据的重传。当一个消息变得过于陈旧而无须发送时(按照应用进程的指导),该消息将被跳过而不再发送到对端。这意味着不是所有数据都确保到达关联的另一端。

任何时候,多个进程可能同时使用TCP、UDP和SCTP这3种传输层协议中的任何一种。这3种协议都使用16位整数的端口号(port number)来区分这些进程。

当一个客户想要跟一个服务器联系时,它必须标识想要与之通信的这个服务器。TCP、UDP和SCTP定义了一组众所周知的端口(well-known port),用于标识众所周知的服务。举例来说,支持FTP的任何TCP/IP实现都把21这个众所周知的端口分配给FTP服务器。分配给简化文件传送协议(Trivial File Transfer Protocol,TFTP)的是UDP端口号69。

另一方面,客户通常使用短期存活的临时端口(ephemeral port)。这些端口号通常由传输层协议自动赋予客户。客户通常不关心其临时端口的具体值,而只需确信该端口在所在主机中是唯一的就行。传输协议的代码确保这种唯一性。

IANA(the Internet Assigned Numbers Authority,因特网已分配数值权威机构)维护着一个端口号分配状况的清单。该清单一度作为RFC多次发布;RFC 1700[Reynolds and Postel 1994]是这个系列的最后一个。RFC 3232[Reynolds 2002]给出了替代RFC 1700的在线数据库的位置:http://www.iana.org/。端口号被划分成以下3段。

(1)众所周知的端口为 0~1023。这些端口由IANA分配和控制。可能的话,相同端口号就分配给TCP、UDP和SCTP的同一给定服务。例如,不论TCP还是UDP端口号80都被赋予Web服务器,尽管它目前的所有实现都单纯使用TCP。

50

端口号80分配时SCTP尚不存在。新的端口分配将针对这3种协议执行,RFC 2960则声明所有现有的TCP端口号对于使用SCTP的同一服务同样有效。

(2)已登记的端口(registered port)为1024~49151。这些端口不受IANA控制,不过由IANA登记并提供它们的使用情况清单,以方便整个群体。可能的话,相同端口号也分配给TCP和UDP的同一给定服务。例如,6000~6063分配给这两种协议的X Window服务器,尽管它的所有实现当前单纯使用TCP。49151这个上限的引入是为了给临时端口留出范围,而RFC 1700[Reynolds and Postel 1994]所列的上限为65535。

(3)49152~65535是动态的(dynamic)或私用的(private)端口。IANA不管这些端口。它们就是我们所称的临时端口。(49152这个魔数是65536的四分之三。)

图2-10展示了端口号的划分情况和常见的分配情况。

图2-10 端口号的分配

我们要注意图2-10中以下几点。

由于这个原因,许多较早的系统实现的临时端口范围的上限为5 000。5 000这个上限后来发现是一个排版错误[Borman ],本应该是50 000。

51

注意:BSD的保留端口和rresvport函数都跟IANA众所周知端口的后半部分重叠。这是因为IANA众所周知端口早先的上限为255。1992年的RFC 1340(早先的一个"Assigned Numbers"RFC)开始在256~1023之间分配众所周知的端口。1990年的RFC 1060(更早先的一个"Assigned Numbers"RFC)称256~1023之间的端口为Unix标准服务(Unix Standard Services)。20世纪80年代有不少源自Berkeley的服务器在512以后挑选它们的众所周知的端口(留下256~511这个空档)。rresvport函数选择从1023开始往下寻找,直至513。

一个TCP连接的套接字对(socket pair)是一个定义该连接的两个端点的四元组:本地IP地址、本地TCP端口号、外地IP地址、外地TCP端口号。套接字对唯一标识一个网络上的每个TCP连接。就SCTP而言,一个关联由一组本地IP地址、一个本地端口、一组外地IP地址、一个外地端口标识。在两个端点均非多宿这一最简单的情形下,SCTP与TCP所用的四元组套接字对一致。然而在某个关联的任何一个端点为多宿的情形下,同一个关联可能需要多个四元组标识(这些四元组的IP地址各不相同,但端口号是一样的)。

标识每个端点的两个值(IP地址和端口号)通常称为一个套接字

我们可以把套接字对的概念扩展到UDP,即使UDP是无连接的。当讲解套接字函数(bind
connectgetpeername等)时,我们将指明它们在指定套接字对中的哪些值。举例来说,bind函数要求应用程序给TCP、UDP或SCTP套接字指定本地IP地址和本地端口号。

并发服务器中主服务器循环通过派生一个子进程来处理每个新的连接。如果一个子进程继续使用服务器众所周知的端口来服务一个长时间的请求,那将发生什么?让我们来看一个典型的序列。首先,在主机freebsd上启动服务器,该主机是多宿的,其IP地址为12.106.32.254和192.168.42.1。服务器在它的众所周知的端口(本例为21)上执行被动打开,从而开始等待客户的请求,如图2-11所示。

52

图2-11 TCP服务器在端口21上执行被动打开

我们使用记号{*:21, *:*}指出服务器的套接字对。服务器在任意本地接口(第一个星号)的端口21上等待连接请求。外地IP地址和外地端口都没有指定,我们用“*.*”来表示。我们称它为监听套接字(listening socket)。

我们用冒号来分割IP地址和端口号,因为这是HTTP的用法,其他地方也常见。netstat程序使用点号来分割IP地址和端口号,不过如此表示有时候会让人混淆,因为点号既用于域名(如freebsd.unpbook.com.21),也用于IPv4的点分十进制数记法(如12.106.32.254.21)。

这里指定本地IP地址的星号称为通配(wildcard)符。如果运行服务器的主机是多宿的(如本例),服务器可以指定它只接受到达某个特定本地接口的外来连接。这里要么选一个接口要么选任意接口。服务器不能指定一个包含多个地址的清单。通配的本地地址表示“任意”这个选择。在图1-9中,通配地址通过在调用bind之前把套接字地址结构中的IP地址字段设置成INADDR_ANY来指定。

稍后在IP地址为206.168.112.219的主机上启动第一个客户,它对服务器的IP地址之一12.106.32.254执行主动打开。我们假设本例中客户主机的TCP为此选择的临时端口为1500,如图2-12所示。图中在该客户的下方标出了它的套接字对。

图2-12 客户对服务器的连接请求

当服务器接收并接受这个客户的连接时,它fork一个自身的副本,让子进程来处理该客户的请求,如图2-13所示。(我们将在4.7节中讲解fork函数。)

53

图2-13 并发服务器让子进程处理客户

至此,我们必须在服务器主机上区分监听套接字和已连接套接字(connected socket)。注意已连接套接字使用与监听套接字相同的本地端口(21)。还要注意在多宿服务器主机上,连接一旦建立,已连接套接字的本地地址(12.106.32.254)随即填入。

下一步我们假设在客户主机上另有一个客户请求连接到同一个服务器。客户主机的TCP为这个新客户的套接字分配一个未使用的临时端口,譬如说1501,如图2-14所示。服务器上这两个连接是有区别的:第一个连接的套接字对和第二个连接的套接字对不一样,因为客户的TCP给第二个连接选择了一个未使用的端口(1501)。

图2-14 第二个客户与同一个服务器的连接

通过本例应注意,TCP无法仅仅通过查看目的端口号来分离外来的分节到不同的端点。它必须查看套接字对的所有4个元素才能确定由哪个端点接收某个到达的分节。图2-14中对于同一个本地端口(21)存在3个套接字。如果一个分节来自206.168.112.219端口1500,目的地为12.106.32.254端口21,它就被递送给第一个子进程。如果一个分节来自206.168.112.219端口1501,目的地为12.106.32.254端口21,它就被递送给第二个子进程。所有目的端口为21的其他TCP分节都被递送给拥有监听套接字的最初那个服务器(父进程)。

下面我们将介绍一些影响IP数据报大小的限制。我们首先介绍这些限制,然后就它们如何影响应用进程能够传送的数据进行综合分析。

54~55

我们必须小心这些术语的使用。一个标记为IPv6路由器的设备可能执行分片,不过只是对于那些由它产生的数据报,而绝不是对于那些由它转发的数据报。当该设备产生IPv6数据报时,它实际上作为主机运作。举例来说,大多数路由器支持Telnet协议,管理员就用它来配置路由器。由路由器的Telnet服务器产生的IP数据报是由路由器产生的,而不是由路由器转发的。

你可能注意到,IPv4首部(图A-1)有用于处理IPv4分片的字段,IPv6首部(图A-2)却没有类似的字段。既然分片是例外情况而不是通常情况,IPv6于是引入一个可选首部以提供分片信息。

某些通常用作路由器的防火墙可能会重组分片了的分组,以便查看整个IP数据报的内容。这样做使得不必在防火墙上引入额外的复杂性就能够防止某些攻击。它还要求防火墙设备是进出网络的唯一路径上的设备,从而减少了冗余的机会。

56

路径MTU发现在如今的因特网上是有问题的,许多防火墙丢弃所有ICMP消息,包括用于路径MTU发现的上述消息。这意味着TCP永远得不到要求它降低所发送数据量的信号。编写本书时,IETF已经开始尝试定义不依赖于ICMP出错消息的另一种路径MTU发现方法。

57

图2-15展示了某个应用进程写数据到一个TCP套接字中时发生的步骤。

图2-15 应用进程写TCP套接字时涉及的步骤和缓冲区

每一个TCP套接字有一个发送缓冲区,我们可以使用SO_SNDBUF套接字选项来更改该缓冲区的大小(见7.5节)。当某个应用进程调用write时,内核从该应用进程的缓冲区中复制所有数据到所写套接字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据(或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据),该应用进程将被投入睡眠。这里假设该套接字是阻塞的,它是通常的默认设置。(我们将在第16章中阐述非阻塞的套接字。)内核将不从write系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个TCP套接字的write调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的TCP或应用进程已接收到数据。(我们将在7.5节随SO_LINGER套接字选项详细讨论这一点。)

这一端的TCP提取套接字发送缓冲区中的数据并把它发送给对端TCP,其过程基于TCP数据传送的所有规则(TCPv1的第19章和第20章)。对端TCP必须确认收到的数据,伴随来自对端的ACK的不断到达,本端TCP至此才能从套接字发送缓冲区中丢弃已确认的数据。TCP必须为已发送的数据保留一个副本,直到它被对端确认为止。

58

本端TCP以MSS大小的或更小的块把数据传递给IP,同时给每个数据块安上一个TCP首部以构成TCP分节,其中MSS或是由对端通告的值,或是536(若对端未发送一个MSS选项)。(536是IPv4最小重组缓冲区字节数576减去IPv4首部字节数20和TCP首部字节数20的结果。)IP给每个TCP分节安上一个IP首部以构成IP数据报,并按照其目的IP地址查找路由表项以确定外出接口,然后把数据报传递给相应的数据链路。IP可能在把数据报传递给数据链路之前将其分片,不过我们已经谈到MSS选项的目的之一就是试图避免分片,较新的实现还使用了路径MTU发现功能。每个数据链路都有一个输出队列,如果该队列已满,那么新到的分组将被丢弃,并沿协议栈向上返回一个错误:从数据链路到IP,再从IP到TCP。TCP将注意到这个错误,并在以后某个时刻重传相应的分节。应用进程并不知道这种暂时的情况。

图2-16展示了某个应用进程写数据到一个UDP套接字中时发生的步骤。

图2-16 应用进程写UDP套接字时涉及的步骤与缓冲区

这一次我们以虚线框展示套接字发送缓冲区,因为它实际上并不存在。任何UDP套接字都有发送缓冲区大小(我们可以使用SO_SNDBUF套接字选项更改它,见7.5节),不过它仅仅是可写到该套接字的UDP数据报的大小上限。如果一个应用进程写一个大于套接字发送缓冲区大小的数据报,内核将返回该进程一个EMSGSIZE错误。既然UDP是不可靠的,它不必保存应用进程数据的一个副本,因此无需一个真正的发送缓冲区。(应用进程的数据在沿协议栈向下传递时,通常被复制到某种格式的一个内核缓冲区中,然而当该数据被发送之后,这个副本就被数据链路层丢弃了。)

这一端的UDP简单地给来自用户的数据报安上它的8字节的首部以构成UDP数据报,然后传递给IP。IPv4或IPv6给UDP数据报安上相应的IP首部以构成IP数据报,执行路由操作确定外出接口,然后或者直接把数据报加入数据链路层输出队列(如果适合于MTU),或者分片后再把每个片段加入数据链路层的输出队列。如果某个UDP应用进程发送大数据报(譬如说2000字节的数据报),那么它们相比TCP应用数据更有可能被分片,因为TCP会把应用数据划分成MSS大小的块,而UDP却没有对等的手段。

59

从写一个UDP套接字的write调用成功返回表示所写的数据报或其所有片段已被加入数据链路层的输出队列。如果该队列没有足够的空间存放该数据报或它的某个片段,内核通常会返回一个ENOBUFS错误给它的应用进程。

不幸的是,有些UDP的实现不返回这种错误,这样甚至数据报未经发送就被丢弃的情况应用进程也不知道。

图2-17展示了某个应用进程写数据到一个SCTP套接字中时发生的步骤。

图2-17 应用进程写SCTP套接字时涉及的步骤和缓冲区

既然SCTP是与TCP类似的可靠协议,它的套接字也有一个发送缓冲区,而且跟TCP一样,我们可以用SO_SNDBUF套接字选项来更改这个缓冲区的大小(见7.5节)。当一个应用进程调用write时,内核从该应用进程的缓冲区中复制所有数据到所写套接字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据(或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据),应用进程将被投入睡眠。这里假设该套接字是阻塞的,它是通常的默认设置。(我们将在第16章中阐述非阻塞的套接字。)内核将不从write系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个SCTP套接字的write调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的SCTP或应用进程已接收到数据。

60

这一端的SCTP提取套接字发送缓冲区的数据并把它发送给对端SCTP,其过程基于SCTP数据传送的所有规则(数据传送的细节见[Stewart and Xie 2001]的第5章)。本端SCTP必须等待SACK,在累积确认点超过已发送的数据后,才可以从套接字缓冲区中删除该数据。

图2-18列出了TCP/IP多数实现都提供的若干标准服务。注意,表中所有服务同时使用TCP和UDP提供,并且这两个协议所用端口号也相同。

图2-18 大多数实现提供的标准TCP/IP服务

这些服务通常由Unix主机的inetd守护进程提供(见13.5节)。它们还提供使用标准的Telnet客户程序就能完成的简易测试机制。举例来说,下面就是时间获取和回射这两个标准服务器的测试过程:

aix % telnet freebsd daytime
Trying 12.106.32.254...             Telnet客户输出
Connected to freebsd.unpbook.com.       Telnet客户输出
Escape character is ′^]′.            Telnet客户输出
Mon Jul 28 11:56:22 2003            daytime服务器输出
Connection closed by foreign host.       Telnet客户输出(服务器关闭连接)

aix % telnet freebsd echo
Trying 12.106.32.254...             Telnet客户输出
Connected to freebsd.unpbook.com.       Telnet客户输出
Escape character is ′^]′.            Telnet客户输出
hello,world                   我们键入这行
hello,world                   它由服务器回射回来
^]                         键入Ctrl+]以与Telnet客户交谈
telnet> quit                   告诉客户我们已测试完毕
Connection closed.               这次客户自己关闭连接

在这两个例子中,我们键入主机名和服务名(daytimeecho)。这些服务名由/etc/services文件映射到图2-18所示的端口号,详见11.5节。

61

注意,当我们连接到daytime服务器时,服务器执行主动关闭,然而当连接到echo服务器时,客户执行主动关闭。回顾图2-4,我们知道执行主动关闭的那一端就是历经TIME_WAIT状态的那一端。

为了应付针对它们的拒绝服务攻击和其他资源使用攻击,在如今的系统中,这些简单的服务通常被禁用。

图2-19总结了各种常见的因特网应用对协议的使用情况。

图2-19 各种常见因特网应用的协议使用情况

前两个因特网应用pingtraceroute是使用ICMP协议实现的网络诊断应用。traceroute自行构造UDP分组来发送并读取所引发的ICMP应答。

紧接着是3个流行的路由协议,它们展示了路由协议使用的各种传输协议。OSPF通过原始套接字直接使用IP,RIP使用UDP,BGP使用TCP。

接下来5个是基于UDP的网络应用,然后是7个TCP网络应用和4个同时使用UDP和TCP的网络应用,最后5个是IP电话网络应用,它们或者独自使用SCTP,或者选用UDP、TCP或SCTP。

62

UDP是一个简单、不可靠、无连接的协议,而TCP是一个复杂、可靠、面向连接的协议。SCTP组合了这两个协议的一些特性,并提供了TCP所不具备的额外特性。尽管绝大多数因特网应用(Web、Telnet、FTP和电子邮件)使用TCP,但这3个协议对传输层都是必要的。在22.4节中我们将阐述选用UDP替代TCP的理由。在23.12节中我们将阐述选用SCTP替代TCP的理由。

TCP使用三路握手建立连接,使用四分组交换序列终止连接。当一个TCP连接被建立时,它从CLOSED状态转换到ESTABLISHED状态;当该连接被终止时,它又回到CLOSED状态。一个TCP连接可处于11种状态之一,其状态转换图给出了从一种状态转换到另一种状态的规则。理解状态转换图是使用netstat命令诊断网络问题的基础,也是理解当某个应用进程调用诸如connectacceptclose等函数时所发生过程的关键。

TCP的TIME_WAIT状态一直是一个造成网络编程人员混淆的来源。存在这一状态是为了实现TCP的全双工连接终止(即处理最终那个ACK丢失的情形),并允许老的重复分节从网络中消逝。

SCTP使用四路握手建立关联;使用三分组交换序列终止关联。当一个SCTP关联被建立时,它从CLOSED状态转换到ESTABLISHED状态;当该关联被终止时,它又回到CLOSED状态。一个SCTP关联可处于8种状态之一,其状态转换图给出从一种状态转换到另一种状态的规则。SCTP不像TCP那样需要TIME_WAIT状态,因为它使用了验证标记。

2.1 我们已经提到IPv4(IP版本4)和IPv6(版本6)。IP版本5情况如何,IP版本0、1、2和3又是什么?

(提示:查IANA的“Internet Protocol”注册处。要是你无法访问IANA所在网址 http://www.iana.org,那就查看附录中的解答吧。)

2.2 你从哪里可以找到有关IP版本5的信息?

2.3 在讲解图2-15时我们说过,如果没收到来自对端的MSS选项,本端TCP就采用536这个MSS值。为什么使用这个值?

2.4 给在第1章中讲解的时间获取客户/服务器应用画出类似于图2-5的分组交换过程,假设服务器在单个TCP分节中返回26个字节的完整数据。

2.5 在一个以太网上的主机和一个令牌环网上的主机之间建立一个连接,其中以太网上主机的TCP通告的MSS为1460,令牌环网上主机的TCP通告的MSS为4096。两个主机都没有实现路径MTU发现功能。观察分组,我们在两个相反方向上都找不到大于1460字节的数据,为什么?

63

2.6 在讲解图2-19时我们说过OSPF直接使用IP。承载OSPF数据报的IPv4首部(见图A-1)的协议字段是什么值?

2.7 在讨论SCTP输出时我们说过,SCTP发送端必须等待累积确认点超过已发送的数据,才可以从套接字缓冲区中释放该数据。假设某个选择性确认(SACK)表明累积确认点之后的数据也得到了确认,这样的数据为什么却不能被释放呢?

64

“失而复现的分组”这个译法出自第2版,这一版中改为“陈旧的、延迟的或重复的分节”,却没能准确表达Stevens先生的原意。失而复现的分组并不是超时重传的分组,而是由暂时的路由原因造成的迷途的分组。当路由稳定后,它们又会正常到达目的地,其前提是它们在此前尚未被路由器丢弃。高速网络中32位的序列号短时间内就可能循环一轮重新使用,若不用时间戳选项,失而复现的分组所承载的分节可能与再次使用相同序列号的真正分节发生混淆。——译者注

本图同时给出了这些标准因特网服务的英文名称和中文名称,其中英文名称是正式名称(/etc/services文件使用这些名称)。之所以这么区分是因为本书围绕其中两种服务(回射和时间获取)的实现展开,为区分本书中的实现与各个Unix系统的内部实现,我们用中文名称称呼前者,用英文名称称呼后者(原书也对两者做了类似区分)。另外内部实现的服务总是使用标准端口号,本书实现的服务则可根据情况选择。因此当使用英文名称服务名时,必定与其标准端口号对应。——译者注


在使用TCP编写的应用程序和使用UDP编写的应用程序之间存在一些本质差异,其原因在于这两个传输层之间的差别:UDP是无连接不可靠的数据报协议,非常不同于TCP提供的面向连接的可靠字节流。然而相比TCP,有些场合确实更适合使用UDP,我们将在22.4节探讨这个设计选择。使用UDP编写的一些常见的应用程序有:DNS(域名系统)、NFS(网络文件系统)和SNMP(简单网络管理协议)。

图8-1给出了典型的UDP客户/服务器程序的函数调用。客户不与服务器建立连接,而是只管使用sendto函数(将在下一节介绍)给服务器发送数据报,其中必须指定目的地(即服务器)的地址作为参数。类似地,服务器不接受来自客户的连接,而是只管调用recvfrom函数,等待来自某个客户的数据到达。recvfrom将与所接收的数据报一道返回客户的协议地址,因此服务器可以把响应发送给正确的客户。

图8-1 UDP客户/服务器程序所用的套接字函数

图8-1所示为UDP客户/服务器交互中发生的典型情形的时间线图。我们可以将该图和图4-1所示的TCP的典型交互进行比较。

本章中我们将介绍用于UDP套接字的两个新函数recvfromsendto,并使用UDP重写我们的回射客户/服务器程序。我们还将介绍connect函数在UDP套接字中的用法以及异步错误这个概念。

这两个函数类似于标准的readwrite函数,不过需要三个额外的参数。

#include <sys/socket.h>

ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags, 
          struct sockaddr *from, socklen_t *addrlen); 

ssize_t sendto(int sockfd, const void *buff, size_t nbytes, int flags,
                const struct sockaddr *to, socklen_t *addrlen); 
                                        均返回:若成功则为读或写的字节数,若出错则为-1
239~240

前三个参数sockfdbuffnbytes等同于readwrite函数的三个参数:描述符、指向读入或写出缓冲区的指针和读写字节数。

flags参数将在第14章中讨论recvsendrecvmsgsendmsg等函数时再介绍,本章中重写简单的UDP回射客户/服务器程序用不着它们。时下我们总是把flags置为0。

sendtoto参数指向一个含有数据报接收者的协议地址(例如IP地址及端口号)的套接字地址结构,其大小由addrlen参数指定。recvfromfrom参数指向一个将由该函数在返回时填写数据报发送者的协议地址的套接字地址结构,而在该套接字地址结构中填写的字节数则放在addrlen参数所指的整数中返回给调用者。注意,sendto的最后一个参数是一个整数值,而recvfrom的最后一个参数是一个指向整数值的指针(即值-结果参数)。

recvfrom的最后两个参数类似于accept的最后两个参数:返回时其中套接字地址结构的内容告诉我们是谁发送了数据报(UDP情况下)或是谁发起了连接(TCP情况下)。sendto的最后两个参数类似于connect的最后两个参数:调用时其中套接字地址结构被我们填入数据报将发往(UDP情况下)或与之建立连接(TCP情况下)的协议地址。

这两个函数都把所读写数据的长度作为函数返回值。在recvfrom使用数据报协议的典型用途中,返回值就是所接收数据报中的用户数据量。

写一个长度为0的数据报是可行的。在UDP情况下,这会形成一个只包含一个IP首部(对于IPv4通常为20个字节,对于IPv6通常为40个字节)和一个8字节UDP首部而没有数据的IP数据报。这也意味着对于数据报协议,recvfrom返回0值是可接受的:它并不像TCP套接字上read返回0值那样表示对端已关闭连接。既然UDP是无连接的,因此也就没有诸如关闭一个UDP连接之类事情。

如果recvfromfrom参数是一个空指针,那么相应的长度参数(addrlen)也必须是一个空指针,表示我们并不关心数据发送者的协议地址。

recvfromsendto都可以用于TCP,尽管通常没有理由这样做。

现在,我们用UDP重新编写第5章中简单的回射客户/服务器程序。我们的UDP客户程序和服务器程序依循图8-1中所示的函数调用流程。图8-2描述了它们所使用的函数,图8-3则给出了服务器程序的main函数。

图8-2 使用UDP的简单回射客户/服务器

241

图8-3 UDP回射服务器程序

创建UDP套接字,捆绑服务器的众所周知端口

7~12 我们通过将socket函数的第二个参数指定为SOCK_DGRAM(IPv4协议中的数据报套接字)创建一个UDP套接字。正如TCP服务器程序的例子,用于bind的服务器IPv4地址被指定为INADDR_ANY,而服务器的众所周知端口是头文件<unp.h>中定义的SERV_PORT常值。

13 接着,调用函数dg_echo来执行服务器的处理工作。

图8-4给出了dg_echo函数。

图8-4 dg_echo函数:在数据报套接字上回射文本行

242

读数据报并回射给发送者

8~12 该函数是一个简单的循环,它使用recvfrom读入下一个到达服务器端口的数据报,再使用sendto把它发送回发送者。

尽管这个函数很简单,不过也有许多细节问题需要考虑。首先,该函数永不终止,因为UDP是一个无连接的协议,它没有像TCP中EOF之类的东西。

其次,该函数提供的是一个迭代服务器(iterative server),而不是像TCP服务器那样可以提供一个并发服务器。其中没有对fork的调用,因此单个服务器进程就得处理所有客户。一般来说,大多数TCP服务器是并发的,而大多数UDP服务器是迭代的。

对于本套接字,UDP层中隐含有排队发生。事实上每个UDP套接字都有一个接收缓冲区,到达该套接字的每个数据报都进入这个套接字接收缓冲区。当进程调用recvfrom时,缓冲区中的下一个数据报以FIFO(先入先出)顺序返回给进程。这样,在进程能够读该套接字中任何已排好队的数据报之前,如果有多个数据报到达该套接字,那么相继到达的数据报仅仅加到该套接字的接收缓冲区中。然而这个缓冲区的大小是有限的。我们已在7.5节随SO_RCVBUF套接字选项讨论了这个大小以及如何增大它。

图8-5总结了第5章中的TCP客户/服务器在两个客户与服务器建立连接时的情形。

图8-5 两个客户的TCP客户/服务器小结

服务器主机上有两个已连接套接字,其中每一个都有各自的套接字接收缓冲区。

图8-6展示了两个客户发送数据报到UDP服务器的情形。

图8-6 两个客户的UDP客户/服务器小结

243

其中只有一个服务器进程,它仅有的单个套接字用于接收所有到达的数据报并发回所有的响应。该套接字有一个接收缓冲区用来存放所到达的数据报。

图8-3中的main函数是协议相关的(它创建一个AF_INET协议的套接字,分配并初始化一个IPv4套接字地址结构),而dg_echo函数是协议无关的dg_echo协议无关的理由如下:调用者(在我们的例子中为main函数)必须分配一个正确大小的套接字地址结构,且指向该结构的指针和该结构的大小都必须作为参数传递给dg_echodg_echo绝不查看这个协议相关结构的内容,而是简单地把一个指向该结构的指针传递给recvfromsendtorecvfrom返回时把客户的IP地址和端口号填入该结构,而随后作为目的地址传递给sendto的又是同一个指针(pcliaddr),这样所接收的任何数据报就被回射给发送该数据报的客户。

图8-7给出了UDP客户程序的main函数。

图8-7 UDP回射客户程序

把服务器地址填入套接字地址结构

9~12 把服务器的IP地址和端口号填入一个IPv4的套接字地址结构。该结构将传递给dg_cli函数,以指明数据报将发往何处。

13~14 创建一个UDP套接字,然后调用dg_cli

244

图8-8给出了dg_cli函数,它执行客户的大部分处理工作。

图8-8 dg_cli函数:客户处理循环

7~12 客户处理循环中有四个步骤:使用fgets从标准输入读入一个文本行,使用sendto将该文本行发送给服务器,使用recvfrom读回服务器的回射,使用fputs把回射的文本行显示到标准输出。

我们的客户尚未请求内核给它的套接字指派一个临时端口。(对于TCP客户而言,我们说过connect调用正是这种指派发生之处。)对于一个UDP套接字,如果其进程首次调用sendto时它没有绑定一个本地端口,那么内核就在此时为它选择一个临时端口。跟TCP一样,客户可以显式地调用bind,不过很少这样做。

注意,调用recvfrom指定的第五和第六个参数是空指针。这告知内核我们并不关心应答数据报由谁发送。这样做存在一个风险:任何进程不论是在与本客户进程相同的主机上还是在不同的主机上,都可以向本客户的IP地址和端口发送数据报,这些数据报将被客户读入并被认为是服务器的应答。我们将在8.8节解决这个问题。

与服务器的dg_echo函数一样,客户的dg_cli函数也是协议无关的,不过客户的main函数是协议相关的。main函数分配并初始化一个某个协议类型的套接字地址结构,并把指向该结构的指针及该结构的大小传递给dg_cli

我们的UDP客户/服务器例子是不可靠的。如果一个客户数据报丢失(譬如说,被客户主机与服务器主机之间的某个路由器丢弃),客户将永远阻塞于dg_cli函数中的recvfrom调用,等待一个永远不会到达的服务器应答。类似地,如果客户数据报到达服务器,但是服务器的应答丢失了,客户也将永远阻塞于recvfrom调用。防止这样永久阻塞的一般方法是给客户的recvfrom调用设置一个超时。我们将在14.2节继续讨论这一点。

245

仅仅给recvfrom调用设置超时并不是完整的解决办法。举例来说,如果确实超时了,我们将无从判定超时原因是我们的数据报没有到达服务器,还是服务器的应答没有回到客户。如果客户的请求是“从账户A往账户B转一定数目的钱”而不是我们的简单回射服务器例子,那么请求丢失和应答丢失是极不相同的。我们将在22.5节具体讨论如何给UDP客户/服务器程序增加可靠性。

在8.6节结尾我们提到,知道客户临时端口号的任何进程都可往客户发送数据报,而且这些数据报会与正常的服务器应答混杂。我们的解决办法是修改图8-8中的recvfrom调用以返回数据报发送者的IP地址和端口号,保留来自数据报所发往服务器的应答,而忽略任何其他数据报。然而这样做照样存在一些缺陷,我们马上就会看到。

我们首先把客户程序的main函数(图8-7)改为使用标准回射服务器(图2-13)。这只需把以下赋值语句

servaddr.sin_port = htons(SERV_PORT);

替换为

servaddr.sin_port = htons(7);

这样,我们的客户就可以使用任何运行标准回射服务器的主机了。

我们接着重写dg_cli函数以分配另一个套接字地址结构用于存放由recvfrom返回的结构,如图8-9所示。

图8-9 验证返回的套接字地址的dg_cli函数版本

分配另一个套接字地址结构

9 我们调用malloc来分配另一个套接字地址结构。注意dg_cli函数仍然是协议无关的,因为我们并不关心所处理套接字地址结构的类型,而只是在malloc调用中使用其大小。

比较返回的地址

12~18 在recvfrom的调用中,我们通知内核返回数据报发送者的地址。我们首先比较由recvfrom在值-结果参数中返回的长度,然后用memcmp比较套接字地址结构本身。

我们在3.2节说过,即使套接字地址结构包含一个长度字段,我们也不必设置或检查它。然而此处memcmp比较两个套接字地址结构中的每个数据字节,而内核返回套接字地址结构时,其中长度字段是设置的;因此对于本例,与之比较的另一个套接字地址结构也必须预先设置其长度字段。否则,memcmp将比较一个值为0的字节(因为没有设置长度字段)和一个值为16的字节(假设具体为sockaddr_in结构),结果自然不匹配。

如果服务器运行在一个只有单个IP地址的主机上,那么这个新版本的客户工作正常。然而如果服务器主机是多宿的,该客户就有可能失败。我们针对有两个接口和两个IP地址的主机freebsd4运行本客户程序。

macosx % host freebsd4
freebsd4.unpbook.com has address 172.24.37.94
freebsd4.unpbook.com has address 135.197.17.100
macosx % udpcli02 135.197.17.100
hello
reply from 172.24.37.94:7 (ignored)
goodbye
reply from 172.24.37.94:7 (ignored)

我们指定的服务器IP地址不与客户主机共享同一个子网。

这样指定服务器IP地址通常是允许的。大多数IP实现接受目的地址为本主机任一IP地址的数据报,而不管数据报到达的接口(TCPv2第217~219页)。RFC 1122[Braden 1989]称之为弱端系统模型(weak end system model)。如果一个系统实现的是该RFC中所说的强端系统模型(strong end system model),那么它将只接受到达接口与目的地址一致的数据报。

246~247

recvfrom返回的IP地址(UDP数据报的源IP地址)不是我们所发送数据报的目的IP地址。当服务器发送应答时,目的IP地址是172.24.37.94。主机freebsd4内核中的路由功能为之选择172.24.37.78作为外出接口。既然服务器没有在其套接字上绑定一个实际的IP地址(服务器绑定在其套接字上的是通配IP地址,这一点可通过在freebsd4上运行netstat来验证),因此内核将为封装这些应答的IP数据报选择源地址。选为源地址的是外出接口的主IP地址(TCPv2第232~233页)。还有,既然它是外出接口的主IP地址,如果我们指定发送数据报到该接口的某个非主IP地址(即一个IP别名),那么也将导致图8-9版本客户程序的测试失败。

一个解决办法是:得到由recvfrom返回的IP地址后,客户通过在DNS(第11章)中查找服务器主机的名字来验证该主机的域名(而不是它的IP地址)。另一个解决办法是:UDP服务器给服务器主机上配置的每个IP地址创建一个套接字,用bind捆绑每个IP地址到各自的套接字,然后在所有这些套接字上使用select(等待其中任何一个变得可读),再从可读的套接字给出应答。既然用于给出应答的套接字上绑定的IP地址就是客户请求的目的IP地址(否则该数据报不会被投递到该套接字),这就保证应答的源地址与请求的目的地址相同。我们将在22.6节给出一个这样的例子。

在多宿Solaris系统上,服务器应答的源IP地址就是客户请求的目的IP地址。本节讲述的情形针对源自Berkeley的实现,这些实现基于外出接口选择源IP地址。

我们下一个要检查的情形是在不启动服务器的前提下启动客户。如果我们这么做后在客户上键入一行文本,那么什么也不发生。客户永远阻塞于它的recvfrom调用,等待一个永不出现的服务器应答。然而这是一个很好的例子,它要求我们更多地了解底层协议以理解网络应用进程将发生什么。

首先,我们在主机macosx上启动tcpdump,然后在同一个主机上启动客户,指定主机freebsd4为服务器主机。接着,我们键入一行文本,不过这行文本没有被回射。

macosx % udpcli01 172.24.37.94
hello, world            我们键入这一行
                    但没有任何内容回射回来

图8-10给出了tcpdump的输出。

图8-10 当服务器主机上未启动服务器进程时tcpdump的输出

248

首先我们注意到,在客户主机能够往服务器主机发送那个UDP数据报之前,需要一次ARP请求和应答的交换。(我们把这个交换保留在tcpdump的输出中,是为了强调在IP数据报可发往本地网络上另一个主机或路由器之前,还是有可能出现ARP请求-应答的。)

我们从第3行看到客户数据报发出,然而从第4行看到,服务器主机响应的是一个"port unreach-able"(端口不可达)ICMP消息。(长度13是12个字符加换行符。)不过这个ICMP错误不返回给客户进程,其原因我们稍后讲述。客户永远阻塞于图8-8中的recvfrom调用。我们还指出ICMPv6也有端口不可达错误类型,类似于ICMPv4(见图A-15和图A-16),因此这里讨论的结果对于IPv6也类似。

我们称这个ICMP错误为异步错误(asynchronous error)。该错误由sendto引起,但是sendto本身却成功返回。回顾2.11节,我们知道从UDP输出操作成功返回仅仅表示在接口输出队列中具有存放所形成IP数据报的空间。该ICMP错误直到后来才返回(图8-10所示为4ms之后),这就是称其为异步的原因。

一个基本规则是:对于一个UDP套接字,由它引发的异步错误却并不返回给它,除非它已连接。我们将在8.11节讨论如何给UDP套接字调用connect。很少有人明白套接字最初实现时为什么做此设计决策。(实现内涵在TCPv2第748~749页讨论。)

考虑在单个UDP套接字上接连发送3个数据报给3个不同的服务器(即3个不同的IP地址)的一个UDP客户。该客户随后进入一个调用recvfrom读取应答的循环。其中有2个数据报被正确递送(也就是说,3个主机中有2个在运行服务器),但是第三个主机没有运行服务器。第三个主机于是以一个ICMP端口不可达错误响应。这个ICMP出错消息包含引起错误的数据报的IP首部和UDP首部。(ICMPv4和ICMPv6出错消息总是包含IP首部和所有的UDP首部或部分TCP首部,以便其接收者确定由哪个套接字引发该错误,如图28-21和图28-22所示。)发送这3个数据报的客户需要知道引发该错误的数据报的目的地址以区分究竟是哪一个数据报引发了错误。但是内核如何把该信息返回给客户进程呢?recvfrom可以返回的信息仅有errno值,它没有办法返回出错数据报的目的IP地址和目的UDP端口号。因此做出决定:仅在进程已将其UDP套接字连接到恰恰一个对端后,这些异步错误才返回给进程。

只要SO_BSDCOMPAT套接字选项没有开启,Linux甚至对未连接的套接字也返回大多数ICMP“destination unreachable”(目的地不可达)错误。图A-15中除代码为0、1、4、5、11和12之外的所有ICMP目的地不可达错误均被返回。

我们将在28.7节再次讨论UDP套接字上异步错误的这个问题,并给出一个使用我们自己的守护进程获取未连接套接字上这些错误的简便方法。

249

图8-11以圆点的形式给出了在客户发送UDP数据报时必须指定或选择的四个值。

图8-11 从客户角度总结UDP客户/服务器

客户必须给sendto调用指定服务器的IP地址和端口号。一般来说,客户的IP地址和端口号都由内核自动选择,尽管我们提到过,客户也可以调用bind指定它们。在客户的这两个值由内核选择的情形下我们也提到过,客户的临时端口是在第一次调用sendto时一次性选定,不能改变;然而客户的IP地址却可以随客户发送的每个UDP数据报而变动(假设客户没有捆绑一个具体的IP地址到其套接字上)。其原因如图8-11所示:如果客户主机是多宿的,客户有可能在两个目的地之间交替选择,其中一个由左边的数据链路外出,另一个由右边的数据链路外出。在这种最坏的情形下,由内核基于外出数据链路选择的客户IP地址将随每个数据报而改变。

如果客户捆绑了一个IP地址到其套接字上,但是内核决定外出数据报必须从另一个数据链路发出,那么将会发生什么?这种情形下,IP数据报将包含一个不同于外出链路IP地址的源IP地址(见习题8.6)。

图8-12给出了同样的四个值,但是是从服务器的角度出发的。

图8-12 从服务器角度总结UDP客户/服务器

服务器可能想从到达的IP数据报上取得至少四条信息:源IP地址、目的IP地址、源端口号和目的端口号。图8-13给出了从TCP服务器或UDP服务器返回这些信息的函数调用。

图8-13 服务器可从到达的IP数据报中获取的信息

TCP服务器总是能便捷地访问已连接套接字的所有这四条信息,而且这四个值在连接的整个生命期内保持不变。然而对于UDP套接字,目的IP地址只能通过为IPv4设置IP_RECVDSTADDR套接字选项(或为IPv6设置IPV6_PKTINFO套接字选项)然后调用recvmsg(而不是recvfrom)取得。由于UDP是无连接的,因此目的IP地址可随发送到服务器的每个数据报而改变。UDP服务器也可接收目的地址为服务器主机的某个广播地址或多播地址的数据报,这些我们将在第20章和第21章中讨论。我们将在22.2节讨论recvmsg函数之后,展示如何确定一个UDP数据报的目的地址。

250~251

在8.9节结尾我们提到,除非套接字已连接,否则异步错误是不会返回到UDP套接字的。我们确实可以给UDP套接字调用connect(4.3节),然而这样做的结果却与TCP连接大相径庭:没有三路握手过程。内核只是检查是否存在立即可知的错误(例如一个显然不可达的目的地),记录对端的IP地址和端口号(取自传递给connect的套接字地址结构),然后立即返回到调用进程。

connect函数重载(overload)UDP套接字的这种能力容易让人混淆。如果使用约定,令sockname是本地协议地址,peername是外地协议地址,那么更好的名字本该是setpeername。类似地,bind函数更好的名字本该是setsockname

有了这个能力后,我们必须区分:

对于已连接UDP套接字,与默认的未连接UDP套接字相比,发生了三个变化。

(1)我们再也不能给输出操作指定目的IP地址和端口号。也就是说,我们不使用sendto,而改用writesend。写到已连接UDP套接字上的任何内容都自动发送到由connect指定的协议地址(例如IP地址和端口号)。

其实我们可以给已连接UDP套接字调用sendto,但是不能指定目的地址。sendto的第五个参数(指向指明目的地址的套接字地址结构的指针)必须为空指针,第六个参数(该套接字地址结构的大小)应该为0。POSIX规范指出当第五个参数是空指针时,第六个参数的取值就不再考虑。

(2)我们不必使用recvfrom以获悉数据报的发送者,而改用readrecvrecvmsg。在一个已连接UDP套接字上,由内核为输入操作返回的数据报只有那些来自connect所指定协议地址的数据报。目的地为这个已连接UDP套接字的本地协议地址(例如IP地址和端口号),发源地却不是该套接字早先connect到的协议地址的数据报,不会投递到该套接字。这样就限制一个已连接UDP套接字能且仅能与一个对端交换数据报。

确切地说,一个已连接UDP套接字仅仅与一个IP地址交换数据报,因为connect到多播或广播地址是可能的。

252

(3)由已连接UDP套接字引发的异步错误会返回给它们所在的进程,而未连接UDP套接字不接收任何异步错误。

图8-14就4.4BSD总结了上列第一点。

图8-14 TCP和UDP套接字:可指定目的地协议地址吗?

POSIX规范指出,在未连接UDP套接字上不指定目的地址的输出操作应该返回ENOTCONN,而不是EDESTADDRREQ

图8-15总结了我们给已连接UDP套接字归纳的三点。

图8-15 已连接UDP套接字

应用进程首先调用connect指定对端的IP地址和端口号,然后使用readwrite与对端进程交换数据。

来自任何其他IP地址或端口的数据报(图8-15中我们用“???”表示)不投递给这个已连接套接字,因为它们要么源IP地址要么源UDP端口不与该套接字connect到的协议地址相匹配。这些数据报可能投递给同一个主机上的其他某个UDP套接字。如果没有相匹配的其他套接字,UDP将丢弃它们并生成相应的ICMP端口不可达错误。

作为小结,我们可以说UDP客户进程或服务器进程只在使用自己的UDP套接字与确定的唯一对端进行通信时,才可以调用connect。调用connect的通常是UDP客户,不过有些网络应用中的UDP服务器会与单个客户长时间通信(如TFTP),这种情况下,客户和服务器都可能调用connect

253

DNS提供了另一个例子,如图8-16所示。

图8-16 DNS客户、服务器与connect函数的例子

通常通过在/etc/resolv.conf文件中列出服务器主机的IP地址,一个DNS客户主机就能被配置成使用一个或多个DNS服务器。如果列出的是单个服务器主机(图中最左边的方框),客户进程就可以调用connect,但是如果列出的是多个服务器主机(图中从右边数第二个方框),客户进程就不能调用connect。另外DNS服务器进程通常是处理客户请求的,因此服务器进程不能调用connect

拥有一个已连接UDP套接字的进程可出于下列两个目的之一再次调用connect

第一个目的(即给一个已连接UDP套接字指定新的对端)不同于TCP套接字中connect的使用:对于TCP套接字,connect只能调用一次。

为了断开一个已连接UDP套接字,我们再次调用connect时把套接字地址结构的地址族成员(对于IPv4为sin_family,对于IPv6为sin6_family)设置为AF_UNSPEC。这么做可能会返回一个EAFNOSUPPORT错误(TCPv2第736页),不过没有关系。使套接字断开连接的是在已连接UDP套接字上调用connect的进程(TCPv2第787~788页)。

各种Unix变体断开套接字上连接的方式存在差异,同样的方法可能适合某些系统而不适合其他系统。举例来说,以空的套接字地址结构指针调用connect的方法仅仅适合某些系统(而在另一些系统上,要求第三个参数即套接字地址结构长度为非0)。POSIX规范和BSD手册页面在此帮助不大,只是提到必须使用一个空地址(null address),而根本没有提到出错返回值(甚至成功返回值也没有提到)。最便于移植的解决办法就是清零一个地址结构后把它的地址族成员设置为AF_UNSPEC,再把它传递给connect

另一个存在差异的地方是断开连接前后套接字本地绑定地址的取值。AIX保留被选中的本地IP地址和端口号,即使它们起源于隐式捆绑。FreeBSD和Linux把本地IP地址设置回全0,即使早先调用过bind,端口号也保持不变。Solaris在隐式捆绑时把本地IP地址设置回全0,在显式调用过bind时保持IP地址不变。

254

当应用进程在一个未连接的UDP套接字上调用sendto时,源自Berkeley的内核暂时连接该套接字,发送数据报,然后断开该连接(TCPv2第762~763页)。在一个未连接的UDP套接字上给两个数据报调用sendto函数于是涉及内核执行下列6个步骤:

另一个考虑是搜索路由表的次数。第一次临时连接需为目的IP地址搜索路由表并高速缓存这条信息。第二次临时连接注意到目的地址等于已高速缓存的路由表信息的目的地(我们假设这两个sendto调用有相同的目的地址),于是就不必再次查找路由表(TCPv2第737~738页)。

当应用进程知道自己要给同一目的地址发送多个数据报时,显式连接套接字效率更高。调用connect后调用两次write涉及内核执行如下步骤:

在这种情况下,内核只复制一次含有目的IP地址和端口号的套接字地址结构,相反当调用两次sendto时,需复制两次。[Partridge和Pink 1993]指出,临时连接未连接的UDP套接字大约会耗费每个UDP传输三分之一的开销。

255

现在我们回到图8-8中的dg_cli函数,把它重写成调用connect。图8-17所示为新的函数。

图8-17 调用connectdg_cli函数

所做的修改是调用connect,并以readwrite调用代替sendtorecvfrom调用。该函数不查看传递给connect的套接字地址结构的内容,因此它仍然是协议无关的。图8-7中的客户程序main函数保持不变。

在主机macosx上运行该程序,并指定主机freebsd4的IP地址(它没有在端口9877上运行相应的服务器程序),我们得到如下输出:

macosx % udpcli04 172.24.37.94
hello, world
read error: Connection refused

我们首先注意到,当启动客户进程时我们并没有收到这个错误。该错误只是在我们发送第一个数据报给服务器之后才发生。正是发送该数据报引发了来自服务器主机的ICMP错误。然而当一个TCP客户进程调用connect,指定一个不在运行服务器进程的服务器主机时,connect将返回同样的错误,因为调用connect会造成TCP三路握手,而其中第一个分节导致服务器TCP返送RST(4.3节)。

256

图8-18给出了tcpdump的输出。

图8-18 当运行图8-17中程序时tcpdump的输出

我们还从图A-15中看到,该ICMP错误由内核映射成ECONNREFUSED错误,对应于由err_sys函数输出的消息串:“Connection refused”(连接被拒绝)。

不幸的是,并非所有内核都能像本节的示例那样把ICMP消息返送给已连接的UDP套接字。一般来说,源自Berkeley的内核返回这种错误,而System V内核则不。举例来说,如果我们在一个Solaris 2.4主机上运行同一个客户程序,并connect到没有运行服务器的一个主机上,我们就可以用tcpdump观察并验证服务器主机返回了ICMP端口不可达错误,但是客户的read调用永不返回。这个缺陷在Solaris 2.5中已修复。UnixWare不返回这种错误,而AIX、Digital Unix、HP-UX和Linux都返回这种错误。

现在我们查看无任何流量控制的UDP对数据报传输的影响。首先,我们把dg_cli函数修改为发送固定数目的数据报,并不再从标准输入读。图8-19所示为新的版本,它写2000个1400字节大小的UDP数据报给服务器。

图8-19 写固定数目的数据报到服务器的dg_cli函数

然后,我们把服务器程序修改为接收数据报并对其计数,并不再把数据报回射给客户。图8-20所示为新的dg_echo函数。当我们用终端中断键终止服务器时(相当于向它发送SIGINT信号),服务器会显示所接收到数据报的数目并终止。

图8-20 对接收到数据报进行计数的dg_echo函数

现在我们在主机freebsd上运行服务器,它是一个慢速的SPARC工作站;在RS/6000系统aix上运行客户,两个主机间以100 Mbit/s以太网相连。另外,我们在服务器主机上运行netstat-s命令,在服务器启动前和结束后各运行一次,因为它们输出的统计数据将表明丢失了多少数据报。图8-21给出了服务器主机上的输出。

图8-21 服务器主机上的输出

257~258

客户发出2000个数据报,但是服务器只收到其中的30个,丢失率为98%。对于服务器应用进程或客户应用进程都没有给出任何指示说这些数据报已丢失。这证实了我们说过的话,即UDP没有流量控制并且是不可靠的。本例表明UDP发送端淹没其接收端是轻而易举之事。

检查netstat的输出,我们看到服务器主机(而不是服务器本身)接收到的数据报总数是2000(73208-71208)。“dropped due to full socket buffers”(因套接字缓冲区满而丢弃)计数器的值表示已被UDP接收,但是因为接收套接字的接收队列已满而被丢弃的数据报的数目(TCPv2第775页)。该值为1970(34911971),它加上由应用进程输出的计数值(30)等于服务器主机接收到的2000个数据报。不幸的是,因套接字缓冲区满而丢弃数据报的netstat计数值是全系统范围的值,没有办法确定具体影响到哪些应用进程(例如哪些UDP端口)。

本例中由服务器接收的数据报的数目是不确定的。它依赖于许多因素,例如网络负载、客户主机的处理负载以及服务器主机的处理负载。

如果我们再次运行相同的客户和服务器,不过这一次让客户运行在慢速的Sun主机上,让服务器运行在较快的RS/6000主机上,那就没有数据报丢失。

由UDP给某个特定套接字排队的UDP数据报数目受限于该套接字接收缓冲区的大小。我们可以使用SO_RCVBUF套接字选项修改该值,如7.5节所述。在FreeBSD下UDP套接字接收缓冲区的默认大小为42 080字节,也就是只有30个1400字节数据报的容纳空间。如果我们增大套接字接收缓冲区的大小,那么服务器有望接收更多的数据报。图8-22给出了对图8-20中dg_echo函数的修改,把套接字接收缓冲区设置为240 KB。

图8-22 增大套接字接收队列大小的dg_echo函数

在Sun主机上运行这个服务器程序,在RS/6000主机上运行其客户程序,接收到的数据报计数现在变为103。这比前面使用默认套接字接收缓冲区的例子稍有改善,不过仍然不能从根本上解决问题。

在图8-22中我们为什么把接收套接字缓冲区大小设为220×1 024字节呢?FreeBSD5.1中一个套接字接收缓冲区的最大大小默认为262 144字节(256×1 024),但是由于缓冲区分配策略(见TCPv2第2章),真实的限制是233 016字节。许多基于4.3BSD的早期系统把一个套接字缓冲区的大小限制为52 000字节左右。

已连接UDP套接字还可用来确定用于某个特定目的地的外出接口。这是由connect函数应用到UDP套接字时的一个副作用造成的:内核选择本地IP地址(假设其进程未曾调用bind显式指派它)。这个本地IP地址通过为目的IP地址搜索路由表得到外出接口,然后选用该接口的主IP地址而选定。

图8-23给出了一个简单的UDP程序,它connect到一个指定的IP地址后调用getsockname得到本地IP地址和端口号并显示输出。

图8-23 使用connect来确定输出接口的UDP程序

在多宿主机freebsd上运行该程序,我们得到如下输出:

freebsd % udpcli09 206.168.112.96
local address 12.106.32.254:52329

freebsd % udpcli09 192.168.42.2
local address 192.168.42.1:52330

freebsd % udpcli09 127.0.0.1
local address 127.0.0.1:52331

第一次运行该程序时所用命令行参数是一个遵循默认路径的IP地址。内核把本地IP地址指派成默认路径所指接口的主IP地址。第二次运行该程序时所用命令行参数是连接到另一个以太网接口的一个系统的IP地址,因此内核把本地IP地址指派成该接口的主地址。在UDP套接字上调用connect并不给对端主机发送任何信息,它完全是一个本地操作,只是保存对端的IP地址和端口号。我们还看到,在一个未绑定端口号的UDP套接字上调用connect同时也给该套接字指派一个临时端口。

不幸的是,这项技术并非对所有实现都有效,尤其是源自SVR4的内核。举例来说,它对Solaris 2.5无效,对AIX、HP-UX 11、MacOS X、FreeBSD、Linux、Solaris 2.6及其以后版本却均有效。

现在,我们把第5章中的并发TCP回射服务器程序与本章中的迭代UDP回射服务器程序组合成单个使用select来复用TCP和UDP套接字的服务器程序。图8-24是该程序的前半部分。

图8-24 使用select处理TCP和UDP的回射服务器程序:前半部分

创建监听TCP套接字

14~22 创建一个监听TCP套接字并捆绑服务器的众所周知端口,设置SO_REUSEADDR套接字选项以防该端口上已有连接存在。

创建UDP套接字

23~29 还创建一个UDP套接字并捆绑与TCP套接字相同的端口。这里无需在调用bind之前设置SO_REUSEADDR套接字选项,因为TCP端口是独立于UDP端口的。

图8-25给出了服务器程序的后半部分。

图8-25 使用select处理TCP和UDP的回射服务器程序:后半部分

SIGCHLD建立信号处理程序

30 给SIGCHLD建立信号处理程序,因为TCP连接将由某个子进程处理。我们已在图5-11中给出了这个信号处理函数。

准备调用select

31~32 我们给select初始化一个描述符集,并计算出我们等待的两个描述符的较大者。

调用select

34~41 我们调用select只是为了等待监听TCP套接字的可读条件或UDP套接字的可读条件。既然我们的sig_chld信号处理函数可能中断我们对select的调用,我们于是需要处理EINTR错误。

259~263

处理新的客户连接

42~51 当监听TCP套接字可读时,我们accept一个新的客户连接,fork一个子进程,并在子进程中调用str_echo函数。这与第5章中采取的步骤相同。

处理数据报的到达

52~57 如果UDP套接字可读,那么已有一个数据报到达。我们使用recvfrom读入它,再使用sendto把它发回给客户。

把我们的TCP回射客户/服务器程序转换成UDP回射客户/服务器程序比较容易,然而TCP提供的许多功能也消失了:检测丢失的分组并重传,验证响应是否来自正确的对端,等等。到22.5节我们再回过头来讨论这个话题,并查看如何给UDP应用程序增加一些可靠性。

264

UDP套接字可能产生异步错误,它们是在分组发送完一段时间后才报告的错误。TCP套接字总是给应用进程报告这些错误,但是UDP套接字必须已连接才能接收这些错误。

UDP没有流量控制,这一点很容易演示证明。一般来说,这不成什么问题,因为许多UDP应用程序是用请求-应答模式构造的,而且不用于传送大量数据。

编写UDP应用程序时还有许多问题需要考虑,不过我们把它们留到第22章,也就是在讲解了接口函数、广播和多播以后再作讨论。

8.1 我们有两个应用程序,一个使用TCP,另一个使用UDP。TCP套接字的接收缓冲区中有4096字节的数据,UDP套接字的接收缓冲区中有两个2048字节的数据报。TCP应用程序调用read,指定其第三个参数为4096,UDP应用程序调用recvfrom,指定其第三个参数也为4096。这两个应用程序有什么差别吗?

8.2  在图8-4中,如果我们用clilen来代替sendto的最后一个参数(它原本是len),将会发生什么?

8.3 编译并运行图8-3及图8-4的UDP服务器程序和图8-7及图8-8的UDP客户程序。验证一下客户与服务器能一起工作。

8.4 在一个窗口中运行ping程序,指定-i 60选项(每60秒发一个分组;有些系统用-I而不是-i)、-v选项(输出所有接收到的ICMP错误)和环回地址(通常为127.0.0.1)。我们将用该程序来观察由服务器主机返回的端口不可达ICMP错误。然后,在另一个窗口运行上一个习题中的客户,指定不在运行服务器的某主机的IP地址。将会发生什么?

8.5 对于图8-5我们说过每个已连接TCP套接字都有自己的套接字接收缓冲区。监听套接字情况怎样?你认为它有自己的套接字接收缓冲区吗?

8.6 用sock程序(C.3节)和诸如tcpdump(C.5节)之类的工具来测试我们在8.10节给出的声明:如果客户bind一个IP地址到它的套接字上,但是发送一个从其他接口外出的数据报,那么该数据报仍然包含绑定在该套接字上的IP地址,即使该IP地址与该数据报的外出接口并不相符也不管。

8.7 编译8.13节中的程序并在不同的主机上运行客户和服务器。在客户程序中每次写一个数据报到套接字处放一个printf调用,这会改变接收到分组的百分比吗?为什么?在服务器程序中每次从套接字读一个数据报处放一个printf调用,这会改变接收到分组的百分比吗?为什么?

8.8 对于UDP/IPv4套接字,可传递给sendto的最大长度是多少;也就是说,可装填在一个UDP/IPv4数据报中的最大数据量是多少?UDP/IPv6又有什么不同?
修改图8-8以发送最大长度的UDP数据报,读回它,并输出由recvfrom返回的字节数。

8.9 通过对UDP套接字使用IP_RECVDSTADDR套接字选项,把图8-25的程序修改为符合RFC 1122。

265

这句话是针对本例子所用的主机和网络环境而言的,其中隐含假设从客户主机到服务器主机非共享子网IP地址的路径与从客户主机到服务器主机共享子网IP地址的路径一致。通常情形下这两条路经不一定一致。不注意到这一点,作者随后的解释将难以理解。——译者注


本附录给出IPv4、IPv6、ICMPv4及ICMPv6的概貌。这些材料所提供的额外背景知识对于理解第2章中有关TCP和UDP的讨论会有所帮助。高级套接字编程部分有若干章也使用了IP和ICMP的某些特性,例如IP选项(第27章)以及pingtraceroute程序(第28章)。

IP层提供无连接不可靠的数据报递送服务(RFC 791[Postel ])。它会尽最大努力把IP数据报递送到指定的目的地,然而并不保证它们一定到达,也不保证它们的到达顺序与发送顺序一致,还不保证每个IP数据报只到达一次。任何期望的可靠性(即无差错按顺序不重复地递送用户数据)必须由上层提供支持。对于TCP(或SCTP)应用程序而言,这由TCP(或SCTP)本身完成。对于UDP应用程序而言,这得由应用程序完成,因为UDP是不可靠的;我们在22.5节给出了这样的一个例子。

IP层最重要的功能之一是路由(routing)。每个IP数据报包含一个源地址和一个目的地址。图A-1展示了IPv4数据报首部的格式。

图A-1 IPv4首部格式

869~870

图A-2给出了IPv6首部的格式(RFC 2460[Deering and Hinden 1998])。

图A-2 IPv6首部格式

871~872

IPv4早期规范要求路由器把所转发IPv4分组的TTL字段值或者减去1,或者减去路由器存储该分组的秒数,具体取决于哪个值比较大。其名称“存活时间”就是如此而来。然而在现实中该字段值总是减去1。IPv6要求它的跳限字段总是减去1,因而换了个不同于IPv4的名称。

从IPv4到IPv6的最显著变化自然是IPv6采用更大的地址字段。另一个变化是简化IPv6首部,因为首部越简单,路由器处理起来也更快。这两种首部之间的其他变化还有以下几点。

我们另外指出从IPv4到IPv6的以下重要变更,以防你还是首次接触IPv6。

873

32位长度的IPv4地址通常书写成以点号分隔的4个十进制数,称为点分十进制数记法(dotted-decimal notation),其中每个十进制数代表32位地址4个字节中的某一个。这4个十进制数的第一个标识地址类别,如图A-3所示。历史上IPv4地址曾被划分成5类,其中3类用作功能等同的单播地址,并且从20世纪90年代中期开始随着无类(classless)地址概念的提出而被认为不再存在类别,因而作为单个范围展示。

图A-3 IPv4地址5个类别的范围

无论在何时谈到IPv4网络或子网地址,所说的都是一个32位网络地址和一个相应的32位掩码。掩码中值为1的位涵盖网络地址部分,值为0的位涵盖主机地址部分。既然掩码中值为1的位总是从最左位向右连续排列,值为0的位总是从最右位向左连续排列,因此地址掩码也可以使用表示从最左位向右排列的值为1的连续位数的前缀长度(prefix length)指定。举例来说,掩码是255.255.255.0,则前缀长度为24。这些IPv4地址被认为是无类的,之所以这么称呼,是因为现在掩码是显式指定而非由地址类型暗指的。IPv4网络地址通常书写成一个点分十进制数串,后跟一个斜杠,再跟以前缀长度。图1-16展示了这样的例子。

没有一个RFC排除非连续子网掩码的合法性,不过这种掩码容易造成混淆,也没法以前缀记法表示。因特网域间路由协议BGP4不能表示非连续子网掩码。IPv6同样要求所有地址掩码从最左位开始保持连续。

使用无类地址要求无类路由,它通常称为无类域间路由(classless interdomain routing,CIDR)(RFC 1519[Fuller et al. 1993])。使用CIDR的目的在于减少因特网主干路由表的大小,延缓IPv4地址耗尽的速率。CIDR中每个路径必须伴以一个掩码或前缀长度。地址类型不再暗含掩码。TCPv1的10.8节更详细地讨论CIDR。

874

IPv4地址通常划分子网(RFC 950[Mogul and Postel 1985])。这么做增加了另外一级地址层次:

网络ID和子网ID之间的界线由所分配网络地址的前缀长度确定,而这个前缀长度通常由相应组织机构的ISP赋予。然而子网ID和主机ID之间的界线却由网点选择。某个给定子网上所有主机都共享同一个子网掩码(subnet mask),它指定子网ID和主机ID之间的界线。子网掩码中值为1的位涵盖网络ID和子网ID,值为0的位则涵盖主机ID。

作为一个例子,考虑某个网点被它的ISP赋予一个私用网络地址192.168.42.0/24。这个网点随后把剩余8位划分成3位子网ID和5位主机ID,如图A-4所示。

图A-4 24位网络ID伴以3位子网ID和5位主机ID

图A-5列出了如此划分形成的所有子网。

图A-5 3位子网ID和5位主机ID的子网列表

875

如此划分形成6至8个子网(子网ID为1~6或0~7),每个子网支持30个主机(主机ID为1~30)。RFC 950建议不要使用子网ID各位全为0或全为1的那两个子网(本例子为子网ID分别为0和7的子网),不过如今大多数系统支持这两种格式的子网ID。主机ID各位全为1的地址(本例子的主机ID为31)是相应子网的定向广播地址(20.2节)。主机ID各位全为0的地址用于标识相应子网,同时避免与把0值主机ID用作子网定向广播地址的较旧系统发生冲突。然而如果能够保准子网上不存在这样的系统,那么使用0值主机ID标识一个主机也是可能的。总的来讲,网络程序无需关心子网或主机ID的指定,而应该将IP地址视作不透明的值。

按照约定,地址127.0.0.1赋予环回接口。任何发送到这个IP地址的分组在内部被环送回来作为IP模块的输入,因而这些分组根本不会出现在网络上。我们在同一个主机上测试客户和服务器程序时经常使用该地址。该地址通常为人所知的名字是INADDR_LOOPBACK

网络127.0.0.0/8上任何地址都可以赋予环回接口,但是127.0.0.1是其中最常用的,往往由系统自动配置。

所有32位均为0的地址是IPv4的未指明地址(unspecified address)。这个IP地址只能作为源地址出现在IPv4分组中,而且是在其发送主机处于获悉自身IP地址之前的自举引导过程期间。在套接字API中该地址称为通配地址,其通常为人所知的名字是INADDR_ANY。在套接字API中绑定该地址(例如为了监听某套接字)表示会接受目的地为任何节点的IPv4地址的客户连接。

RFC 1918[Rekhter et al. 1996]留置了若干段地址范围供“私用网际网”(private internets)使用,这些网络不能直接接入到公用因特网中,除非中间介以NAT或代理设备。这些地址范围如图A-6所示。

图A-6 私用IPv4地址范围

这些地址绝不能出现在因特网上,它们是为私用网络保留的。许多小规模网点结合NAT技术使用这些私用地址,在一个或多个因特网上可用的公用IP地址和所用私用地址之间进行地址转换。

876

多宿主机(multihomed host)的传统定义是具有多个接口的主机:例如两个以太网链路或者一个以太网链路加一个点到点链路。每个接口必须有一个唯一的IPv4地址。计量一个主机的接口数是否超过一个以确定它是否多宿时,环回接口不计在内。

路由器按定义是多宿的,因为它把到达某个接口的分组转发到另一个接口。然而多宿主机却不必一定是路由器,除非它们转发分组。事实上一个多宿主机不应该仅仅因为拥有多个接口而自我认定是一个路由器;除非已被配置成作为路由器(典型手段是由系统管理员开启某个配置选项),否则它绝不能扮演这个角色。

然而多宿(multihoming)这个说法现已变得更为一般化,包括两种不同情形(RFC 1122[Braden 1989]的节)。

可见多宿主机的定义是具有多个IP层可见接口(扣除回馈接口)的主机,至于这些接口是物理的还是逻辑的则不必关心。

给予网络负荷极高的某个服务器主机到同一个以太网交换机的多个物理连接,并把这些连接汇聚成一个更高带宽的逻辑连接,这种做法并不鲜见。这样的主机不能因为拥有多个物理接口而被认为是多宿的,因为在IP层看来它们是单个逻辑接口。

多宿也用于另一个上下文中。有多个连接通达因特网的网络也称为多宿的。举例来说,有些网点有两个而非一个通达因特网的连接,以此提供因特网接入的备份能力。SCTP传输协议能从多重连接(通过联系多宿网点)中获益。

IPv6地址有128位,通常书写成以冒号分隔的8个16位值的十六进制数。IPv6地址的128位地址的高序位隐含地址类型(RFC 3513[Hinden and Deering 2003])。图A-7给出了高序位不同取值与所隐含地址类型的关系。

877

图A-7 IPv6地址中高序位的含义

这些高序位称为格式前缀(format prefix)。其中高序8位是00000000的保留地址范围中已经定义未指明地址(unspecified address)、环回地址(loopback address)和嵌入IPv4地址的IPv6地址,后者包括IPv4兼容的IPv6地址(IPv4-compatible IPv6 address)和IPv4映射的IPv6地址(IPv4-mapped IPv6 address),具体稍后讨论。

按照RFC 3513,未指明地址、环回地址、链接局部单播地址(link-local unicast address)、网点局部单播地址(site-local unicast address)和多播地址以外的所有IPv6地址都是全球(或全局)单播地址(global unicast address),不过当前全球单播地址空间的分配限制在以001打头的地址范围,其余地址空间留待将来分配。全球单播地址的一般格式是可汇聚的,从最左位开始往右包含以下各个字段,并如图A-8所示:

图A-8 IPv6全球单播地址一般格式

878

全球路由前缀是赋予某个网点的网络标识(通常具有层次结构),子网ID是该网点内某个链路的标识,接口ID是该链路上某个接口的标识。以000打头地址范围之外的所有全球单播地址都有一个64位的接口ID字段。接口ID必须按照经修正的IEEE EUI-64格式构造。IEEE EUI-64[IEEE 1997]是赋予大多数LAN接口卡的48位IEEE 802 MAC地址的一个超集,修正它们的目的仅仅是略微方便系统管理员在硬件接口地址不可得的情况下(例如点到点链路或隧道端点)手工配置非全球ID而已。要是可能的话,IPv6应该基于一个接口的硬件MAC地址自动赋予它一个接口ID。构造基于经修正EUI-64的接口ID的细节详见RFC 3513[Hinden and Deering 2003]附录A。注意,以000打头地址范围之内的全球单播地址(例如IPv4兼容的IPv6地址和IPv4映射的IPv6地址)没有接口ID字段在大小或结构上的如此限制。

既然一个经修正IEEE EUI-64可以是某个给定接口的全球唯一标识,而一个接口也可以标识一个用户,经修正的IEEE EUI-64格式于是唤起所谓的隐私性考虑。举例来说,通过追踪某个给定用户所携带的笔记本电脑产生的IPv6地址中嵌入的EUI-64值,该用户的行为和运动有可能被掌握。RFC 3041[Narten and Draves 2001]讲解了接口ID的隐私性扩展,它能够每天数次变更接口ID以避免暴露隐私。

6bone是一个用于早期IPv6协议测试的虚拟网络(B.3节)。以001打头的那部分全球单播地址范围开始分配之后,6bone就按照使用其中某个特殊格式的原定计划把以0x5f打头的临时性6bone地址更换成了以0x3ffe打头的永久性6bone地址(RFC 2471[Hinden, Fink, and Postel 1998]),如图A-9所示。

图A-9 用于6bone的IPv6测试地址

6bone地址的高序两字节是0x3ffe。32位的6bone网点ID由6bone行动主席(the chair of the6bone activity)赋予每个加入站点,意在体现现实环境中IPv6地址如何分配。6bone行动正在下马之中[Fink and Hinden 2003],因为IPv6生产性部署业已起步(2002年分配的生产性地址量超过6bone在8年内分配的地址量)。子网ID和接口ID如同全球单播地址格式,分别用于标识子网和接口。

我们在11.2节展示了图1-16中名为freebsd的主机的IPv6地址为3ffe:b80:8d:1:a00: 20ff:fea7:686b。其中6bone网点ID是0x0b80lf8d,子网ID是0x1。低序64位是该主机以太网卡MAC地址的经修正IEEE EUI-64值。

IPv4映射的IPv6地址允许在因特网向IPv6过渡时期让运行在同时支持IPv4和IPv6的主机上的IPv6应用进程能够与只支持IPv4的主机通信。这些地址是在IPv6应用进程查询某个只有IPv4地址的主机的IPv6地址时由DNS解析器自动创建的(图11-8)。

879

我们从图12-4看到在IPv6套接字上使用这种类型的地址导致往目的地IPv4主机发送IPv4数据报。这些地址并不保存在任何DNS数据文件中,它们是由解析器按需创建的。

图A-10展示了IPv4映射的IPv6地址的格式。低序32位含有一个IPv4地址。

图A-10 IPv4映射的IPv6地址

书写IPv6地址时,值为0的连续数串可以简写成两个冒号。另外,嵌在其中的IPv4地址使用点分十进制数记法书写。举例来说,我们可以把IPv4映射的IPv6地址0:0:0:0:0:FFFF:12.106.32.254简写成::FFFF:12.106.32.254

IPv4兼容的IPv6地址也用于从IPv4到IPv6的过渡时期(RFC 2893[Gilligan and Nordmark 2000])。如果一个同时支持IPv4和IPv6的主机没有邻居IPv6路由器,那么它的系统管理员应该创建一个含有IPv4兼容的IPv6地址的DNS AAAA记录。有待往这个兼容地址发送IPv6数据报的任何其他IPv6主机将先为这些IPv6数据报封装一个IPv4首部再发送;这种发送方式称为自动隧道(automatic tunnel)。然而IPv6部署上的一些考虑却削弱了这种地址的如此用途。我们将在B.3节讨论隧穿(tunneling),并在图B-2中给出在一个IPv4数据报中封装一个IPv6数据报的例子。需注意的是,6bone上的每个隧道都是经配置的隧道(configured tunnel),譬如说由系统管理员通过某个启动文件预先配置;然而对于IPv4兼容的IPv6地址,只有地址需要手工配置(例如作为一个AAAA记录置于某个DNS数据文件中),隧穿(也就是隧道形成)则是自动的。

图A-11展示了IPv4兼容的IPv6地址的格式。这种类型地址的一个例子是::12.106.32.254

图A-11 IPv4兼容的IPv6地址

当使用SIIT IPv4/IPv6过渡机制(RFC 2765[Nordmark 2000])时,IPv4兼容的IPv6地址也可以作为非隧穿IPv6分组的源地址或目的地址。

由127个值为0位后跟单个值为1位构成的IPv6地址(书写成::1)是IPv6的环回地址。在套接字API中,环回地址为人所知的名字是in6addr_loopbackIN6ADDR_LOOPBACK_INIT

880

所有128位值均为0的IPv6地址(书写成0::0或干脆::)是IPv6的未指明地址。这个地址在IPv6分组中只能作为源地址出现,而且是在其发送主机处于获悉自身IP地址之前的自举引导过程期间。

在套接字API中该地址称为通配地址,其为人所知的名字是in6addr_anyIN6ADDR_ ANY_INIT。通过绑定该地址的套接字发送IPv6分组时,内核会选择一个本地地址作为源地址,除非尚未配置任何本地地址(这种情况下就以未指明地址为源地址);通过绑定这个地址的套接字接收IPv6分组时,内核把没法递送到绑定更明确地址之套接字的接收IPv6分组递送到这个通配套接字。

链路局部地址用在单个链路上,并且是在已知数据报不会被转发的前提下。这种地址的使用例子包括自举引导阶段的自动地址配置和以后的邻居发现(类似IPv4的ARP)。图A-12展示了这些地址的格式。

图A-12 IPv6链路局部地址

这些地址总是以0xfe80打头。IPv6路由器绝不能把源地址或目的地址为链路局部地址的数据报转发到其他链路。我们在11.2节给出了与名字aix_611相关联的链路局部地址。

在本书写至此处时,IETF的IPv6工作组已决定废弃当前形式的网点局部地址。即将来临的替换品使用还是不使用原初为网点局部地址定义的地址范围(fec0/10)尚未知晓。这种地址本打算用于某个网点范围内无需全球路由前缀的寻址。图A-13展示了这些地址原初定义的格式。

图A-13 IPv6网点局部地址

这些地址总是以0xfec0开头。IPv6路由器绝不能把源地址或目的地址为网点局部地址的数据报转发到所在网点以外。

881

ICMP是任何IPv4或IPv6实现都必需的有机组成部分。它通常用于在IP节点(即路由器和主机)之间互通出错消息或信息性消息,不过应用程序偶尔也会使用它们获取信息性消息或出错消息,例如pingtraceroute程序(第28章)都使用ICMP。

ICMPv4和ICMPv6消息的前32位是相同的,如图A-14所示。RFC 792[Postel 1981b]讲述了ICMPv4,RFC2463[Conta and Deering 1998]讲述了ICMPv6。

8位类型(type)字段是ICMPv4或ICMPv6消息的类型,有些类型有一个8位代码(code)字段提供额外信息。校验和(checksum)字段是标准的网际网检验和,不过在具体校验哪些字段上ICMPv4和ICMPv6存在差异:ICMPv4检验和仅仅校验ICMP消息本身,ICMPv6检验和的校验范围还包括IPv6伪首部。

图A-14 ICMPv4和ICMPv6消息的格式

从网络编程角度看,我们需要知道哪些ICMP消息能够返送到应用进程,哪些条件导致出错以及这些出错消息如何返送到应用进程。图A-15列出了所有的ICMPv4消息以及FreeBSD对它们的处理,图A-16则列出了ICMPv6消息。倒数第二栏指出导致向发送主机返送ICMP出错消息的IP数据报发送操作返回给调用进程的errno变量值。对于TCP应用进程,这些错误只是在TCP最终放弃重传尝试时才返回。对于使用已连接套接字的UDP应用进程,这些错误由下次发送或接手操作返回,但在使用已连接套接字时是个例外(如8.9节所述)。

882

图A-15 FreeBSD对ICMPv4消息类型的处理

883

图A-16 ICMPv6消息

其中端口不可达(对于ICMPv4类型为3代码为3,对于ICMPv6类型为1代码为4)仅用于自身无法通告对端某个端口上无进程在监听的传输协议。TCP为此发送RST分节,因而不需要这个ICMP出错消息。作为路由器运作(即转发分组)的系统忽略重定向(对于ICMPv4类型为5,对于ICMPv6类型为137)。

记号“用户进程”意味着内核不处理这样的消息,它们由打开原始套接字的用户进程处理。我们还得注意不同的实现对于特定的消息可能有不同的处理。举例来说,尽管Unix系统通常在用户进程中处理路由器征求与路由器通告,其他实现却有可能在内核中处理这些消息。

ICMPv6为出错消息(类型1~4)清除类型字段的高序位,并为信息性消息(类型128~137)设置该位。

884

类似TCP的最大分节生命期MSL概念,不过一个IP数据报被分片成多个分组之后,每个分组各自有网络存活期,整个IP数据报的网络存活期可视为各个分组网络存活期之和。——译者注

这些地址的子网掩码是0xffffffe0或255.255.255.224。整个网络地址(192.168.42.0/24)和各个子网地址(例如192.168.42.32/27)使用同样的前缀表示记法。

图A-7既不完备又存在不少谬误,译者根据RFC 3513和Stevens先生在第2版中给出的图修订为下面的图A-7A。——译者注

图A-7A IPv6地址中高序位的含义(修订)


往TCP中加入一个新特性时,对于该特性的支持只需在使用TCP的主机上实现,路由器则无需改动。举例来说,在RFC 1323中定义的长胖管道支持就是这样的一个特性,它要求的变动正在缓慢地出现在TCP的主机实现中,当建立一个新的TCP连接时,每端都可能判定对端是否已支持这个新特性。如果两端主机都支持该特性,它就可能被用上。

这一点不同于对IP层所做的改动,譬如说20世纪80年代末的多播和90年代中的IPv6,因为这些新特性要求所有主机和所有路由器都进行改动。然而人们不愿意等到所有系统都升完级才开始使用这些新特性。为此,人们使用隧道(tunnel)在已有的IPv4因特网上建立虚拟网络(virtual network)。

我们使用隧道构造的第一个虚拟网络例子是MBone,它起始于1992年前后[Eriksson 1994]。如果一个LAN上有2个或多个主机支持多播,多播应用系统就可以运行在所有这些主机上并彼此通信。为了把这样的LAN连接到另外一个同样有主机支持多播的LAN上,这两个LAN中需各有一个主机相互之间配置出一个隧道,如图B-1所示。我们在该图中用数字标出了如下的步骤。

图B-1 MBone上使用的IPv4套IPv4封装

885

(1)源主机MH1上的某个应用进程向一个D类地址发送一个多播数据报。

(2)我们把它展示成一个UDP数据报,因为大多数多播应用程序都使用UDP。我们已在第21章较具体地讨论过多播以及如何发送和接收多播数据报。

(3)该数据报由本LAN上所有支持多播的主机接收,其中包括MR2。我们把MR2标注成也用作多播路由器,运行着执行多播路由功能的mrouted程序。

(4)MR2在该数据报之前冠以另一个IPv4首部,并把这个新首部的目的IPv4地址设置成隧道端点(tunnel endpoint)MR5的单播地址。这个单播地址是由MR2的系统管理员配置并由mrouted程序在启动阶段读入的。类似地,在隧道对端的MR5上也配置了MR2的单播地址。新的IPv4首部的协议字段被设置成4,代表IPv4套IPv4(IPv4-in-IPv4)封装。MR2然后把该数据报发送给下一跳路由器UR3,它被明确地标注成一个单播路由器。也就是说UR3不理解多播,这正是我们使用隧道的原因。新的IPv4数据报的阴影部分相比步骤1所发送的数据报除了所封装IPv4首部的TTL字段递减外没有其他变化。

(5)UR3查找最外层IPv4首部中的目的IPv4地址,然后把该数据报转发给下一跳路由器UR4,它是另外一个单播路由器。

(6)UR4把该数据报递送到它的目的地MR5,它是隧道端点之一。

(7)MR5接收该数据报,发现其协议字段指明IPv4套IPv4封装,于是去除最外层IPv4首部,然后把该数据报的剩余部分(也就是在顶部LAN上多播过的UDP数据报的一个副本)作为一个多播数据报输出到自己所在的LAN上。

(8)底部LAN上的所有支持多播的主机都接收到这个多播数据报。

最终结果是在顶部LAN上发送的多播数据报被同样作为多播数据报在底部LAN上传送。即使跟这两个LAN分别相连的那两个路由器以及它们之间的所有因特网路由器都没有多播能力,该结果也照样发生。

本例中我们展出每个LAN各有一个主机通过运行mrouted程序提供多播路由功能。这是MBone一开始的做法。然而到了1996年左右,多播路由功能开始出现在大多数主要路由器厂商生产的路由器中。要是图B-1中的那两个单播路由路UR3和UR4具有多播能力,我们就根本不需要运行mrouted,因为UR3和UR4将用作多播路由器。然而只要UR3和UR4之间仍然有无多播能力的其他路由器,隧道就是必需的。这时的隧道端点将是MR3(UR3的能多播替代物)和MR4(UR4的能多播替代物),而不是MR2和MR5。

在图B-1所示的情形中,每个多播分组在顶部和底部的LAN上均出现两次:一次是作为一个多播分组,另一次是作为隧道内的一个单播分组穿行在运行着mrouted的主机和下一跳单播路由器之间(例如MR2和UR3之间以及UR4和MR5之间)。这个额外的副本是隧穿的代价。把图B-1中的那两个单播路由器UR3和UR4替换成多播路由器(称为MR3和MR4)的优势在于避免每个多播分组的这个额外副本出现在LAN上。即使MR3和MR4之间因为某些中间路由器(图中未展示)没有多播能力而必须建立一个隧道,这种替换依然优势明显,毕竟能够避免在每个LAN上复制副本。

MBone如今已被原生(native)多播网络取代而几乎不复存在。在因特网多播基础设施中仍可能出现隧道,不过它们往往存在于同一个ISP内部的多播路由器之间,对于最终用户是不可见的。

6bone是出于类似MBone的原因于1996年创建的一个虚拟网络:由支持IPv6的主机构成的各个孤岛上的用户希望使用一个虚拟网络连接在一起,而不必等到所有的中间路由器都变成支持IPv6。本书写至此处时,6bone已因人们更偏好原生IPv6部署而趋于淘汰,估计到2006年6月6bone将停止运作[Fink and Hinden 2003]。我们讨论6bone是因为它是展示经配置隧道的一个例子。我们将在B.4节把这个例子扩展成包含动态隧道。图B-2展示的例子中有两个支持IPv6的LAN使用一个隧道彼此连接,而该隧道穿越的中间路由器只支持IPv4。我们还在该图中用数字标出了如下的步骤。

图B-2 6bone上使用的IPv4套IPv6封装

886~887

(1)顶部LAN上的主机H1发送一个承载某个TCP分节的IPv6数据报到底部LAN上的主机H4。我们把这两个主机标注成“IPv6主机”,不过它们均可能还运行IPv4。H1上的IPv6路由表指定主机HR2为下一跳路由器,因此这个IPv6数据报事实上先被数据链路发送给主机HR2,再由它转发。

(2)主机HR2有一个到达主机HR3的经配置隧道。该隧道通过在IPv4数据报中封装IPv6数据报(称为IPv4套IPv6封装)使得IPv6数据报能够穿越IPv4因特网在两个隧道端点之间传送。IPv4协议字段的值为41。我们指出隧道两端的那两个IPv4/IPv6主机HR2和HR3还同时扮演IPv6路由器角色,因为它们都把从一个接口接收到的IPv6数据报转发到另一个接口。经配置隧道也计作一个接口,不过它是一个虚拟接口而不是一个物理接口。

888

(3)隧道端点之一的HR3接收这个经过封装的数据报,剥掉它的IPv4首部后把剩下的IPv6数据报发送到自己所在的LAN上。

(4)目的主机H4接收到这个IPv6数据报。

在“Connection of IPv6 Domains via IPv4 Clouds”(RFC 3056[Carpenter and Moore 2001])一文中详述的6to4过渡机制是在图B-2所示虚拟网络中动态创建隧道的一个方法。与先前设计的动态隧穿机制不同的是,6to4仅仅涉及执行隧穿处理的路由器,先前设计的机制却要求每个个体主机都有一个IPv4地址并清楚隧穿机制本身。6to4使得配置更为简单,并有一个便于实施安全策略的集中位置。6to4功能还允许与在网络边界常见的NAT/防火墙功能(例如处于某个DSL或线缆调制解调器连接的用户端的一个小型NAT/防火墙设备)并置。

6to4地址格式如图B-3所示,处于2002/16范围之内。16位格式前缀0x2002之后跟以32位IPv4地址,两者共同构成公网拓扑ID,剩下16位子网ID和64位接口ID。举例来说,与我们的主机freebsd(其IPv4地址为12.106.32.254)对应的6to4前缀是2002:c:20fe/48

图B-3 6to4地址

6to4相比6bone的优势体现在构成6to4基础设施的隧道是自动建立的,不需要预先进行配置。使用6to4的网点使用一个众所周知的IPv4任播地址(RFC 3068[Huitema 2001])192.88.99.1配置一个默认路由器,它对应于IPv6地址2002:c058:6301::。愿意扮演6to4网关角色的原生(native)IPv6基础设施上的路由器必须通告一个去往2002/16的路径,然后把接收到的IPv6数据报封装在IPv4数据报中转发出去,所用IPv4目的地址取自嵌在6to4地址中的IPv4地址。这些路由器既可以局部于一个网点或一个区域,也可以是全球的,具体取决于它们的路径通告范围。

这些虚拟网络的最终目标是随着时间的推移,当中间环节的路由器逐渐获得所需的功能(就MBone而言是多播路由,就6bone和其他过渡机制而言是IPv6路由)之后,它们将消失。

889


相关图书

Linux常用命令自学手册
Linux常用命令自学手册
庖丁解牛Linux操作系统分析
庖丁解牛Linux操作系统分析
Linux后端开发工程实践
Linux后端开发工程实践
轻松学Linux:从Manjaro到Arch Linux
轻松学Linux:从Manjaro到Arch Linux
Linux高性能网络详解:从DPDK、RDMA到XDP
Linux高性能网络详解:从DPDK、RDMA到XDP
跟老韩学Linux架构(基础篇)
跟老韩学Linux架构(基础篇)

相关文章

相关课程