计算机网络知识总结(1) - 传输层
计算机网络是计算机相关的重点知识之一,也是现在进行网络编程、网络通信的基础知识。我把自己学到的知识总结一遍,既当作复习,也当作交流,欢迎读者提出质疑和批判。
〇. 网络体系分层
计算机网络相对而言比较复杂,从信息在终端上的收发到物理介质的传输,阶段长而且各阶段实现不同,因此有必要进行分阶段也就是分层的操作,使得各阶段分工明确,便于技术的维护和提升。目前主流的分层理论有两类,一是OSI的七层模型,在上世纪70年代末提出并完成理论;另一个是TCP/IP的四层协议。两者相比,特点有如下:
- OSI模型更细分,从物理信号传输到用户的使用都有相对应的分层;
- TCP/IP模型更贴近实现,统合了OSI中分工较模糊的上三层成为一个应用层,但是对物理层次上的通信并不关心,统称物理层和数据链路层为一个层;
- 二者共同的要求是各层次的服务之间相对独立,即维护更新某一层的技术不应当对其他层尤其是上下两层产生影响,每一层只需要提供相应的接口服务即可;
- 真正实现物理上的通信的只有最下层,其他层的通信都属于逻辑上的通信。
本系列文章采用了二者折衷的模型,亦即五层模型,即TCP/IP模型的上层,OSI模型的下层。本文讲解两类模型的共同层次——传输层,也是进行网络开发时比较关注的层。
一 传输层的功能
应用层实现了对使用者也就是上网冲浪的人的服务,而网络层则负责数据在不同的网络中进行传递,即端到端的传输服务,传输层的功能则是实现进程到进程的通信服务。现代的通用计算机,无论大型点的服务器,或是个人电脑,或是更小的平板、手机,同一时刻都有多个应用程序在进行网络通信,而实现网络层功能的硬件大多是网卡、路由器,无法顾及到多进程的网络通信服务,因此有必要在通信中端内部实现这样的服务:它能实现多个进程的网络通信服务,并且利用网络层的服务,使得多个进程的网络通信有理有序,互相之间没有干扰,否则就会像大学快递站不分类包裹,进程们就像没有头绪的大学生一样在包裹里乱拣。
因此传输层的主要功能就是实现如何在两个通信实体上的两个进程之间进行有效的通信,当然传输层还有其他功能诸如拥塞控制。传输层的经典协议自然是大名鼎鼎的UDP协议和TCP协议,下面阐释两种协议的内容。
二 UDP协议
1.UDP协议的内容
UDP协议,即用户数据报协议(User Datagram Protocol),是一个面向报文的、不可靠的传输协议。UDP协议以报文发送数据,但是目的是尽最大努力交 付,在发送前发送方和接收方不会发送信息建立连接,而是有报文就发送,收到报文就处理,同时在发送时不会关心包的大小,而UDP协议是有最大发送字节大小限制的,因此应用在发送报文时应当控制发送数据的大小。
2.UDP协议的报头
什么是报头? 在发送数据的过程中,各层会采用类似快递添加包装一样的方法,在收到上层的内容并转发到下层时在头部添加数据(有时也会在报尾),以此来添加有关收发的数据,或者作为边界,等等其他功能。而在接收数据的过程中,各层的服务便会读取这些添加的数据来进行分发、重新合并、校验等工作,最终得到最初要发送的数据。这个过程很类似于一层层地剥开洋葱。
UDP的报头内容非常简单,仅有四个内容:发送方端口,接收方端口,UDP长度,UDP校验和。
端口(Port)是传输层进行服务的重要内容,通过端口传输层将每个进程的通信隔离起来,也就是说端口和进程之间是一种绑定的关系。UDP协议中指明了发送方和接收方的端口,因此在通过UDP协议通信时便可通过端口实现进程间的通信。可以看到每个端口有16位,也就是说端口最多有2^16=65536个。注意0~1023端口已被保留作为固定服务的端口,因此在编写程序和日常使用时如非必要(如监听某个服务),不要使用这些端口。
常见被保留的端口: 21:FTP文件传输服务 22:邮件传输服务 53:DNS域名解析 80:HTTP协议传输 443:HTTPS协议传输
UDP长度指的是包含UDP报头的报文总长度,因为报头是8字节的,故数据的内容最大为65523-8=65515。为什么是65523?因为下层的IP协议要求报文最大长度为65535字节,除去IP协议的12字节报头后,这里只留下65523字节来装UDP协议的报文了。校验和字段则是校验报头、数据和一个概念性的、实际不存在的伪头。这个字段提供了一定的校验功能,不过也可以简单地全置为0,表示发送时没有进行校验。
总的来说,UDP的内容比较简单,提供的功能也很少,它不提供流量控制、拥塞控制、重传机制,因此传输较为不可靠,适合那些要求可靠性不高,或者要求高即时的服务,如DNS协议就是基于UDP协议的,以及语言通话、视频直播等要求高即时,不要求高准确性的服务(想象一下,语言通话缺失一秒钟和音质变差一秒钟的差别)。
三.TCP协议
1.TCP协议的内容
TCP协议,即传输控制协议(Transmission Control Protocol),是一个面向字节的、可靠的传输服务,相比较UDP协议,它更细心的关注报文能否正确无误地到达目的地,还有在进行传输时网络的状态如何。TCP协议的传输有如下三个阶段:建立连接、传输数据、终止连接,三个阶段缺一不可,这也是TCP能提供可靠传输的依赖。
2.TCP协议的报头
可以看出TCP报头多了很多内容,现在一个一个分析。 首先是端口号,这个和UDP协议中是一样的。 序号指的是在一次传输连接中报文的序号。因为一次传输中可以发送多个报文,也有可能是一个很长的报文被分割成了多段(例如文件传输),因此需要序号来表明次序。确认号则是发送方期待的下一个序号,它进行了累计确认,比如当这个字段为100时表明截至99号报文都已经正确收到,期待发送100号报文。注意传输数据时双方都在进行通信,一方发送目的数据,一方发送确认报文。 TCP头长度指的是报头的长度,因为选项字段的长度是可选的。注意它的数字是说报头包含了多少个32位的字,而不是字节。 之后的4位是没有使用的,用来保留,等待之后添加新功能。
接下来是8个1bit的标志位:
- CWR和ECE用作拥塞控制的信号,当接收端收到网络拥塞的消息时,设置ECE以告知发送方降低发送速度,发送方收到后再设置CWR信号,告知其已经放缓速度。
- URG表示是否启用紧急指针字段,这个字段表示从当前序号开始,找到紧急数据的字节偏移量。这一功能是中断消息的一个途径,但不经常使用。
- ACK字段为1表示确认字段有效,为0表示确认字段无效,可忽略。
- PSH表示这是推送数据,请求接收端收到此数据后立即交给应用程序。
- RST用来重置连接,可能由于主机崩溃等原因。也会用来拒收一个段,或者拒绝连接请求。
- SYN被用于建立连接请求,下文会讲述连接的整个过程。FIN也是同样的使用,不过是在结束连接时使用。
窗口大小用来进行流量控制,它表明从已确认的字节算起,还可以再发送多少字节。接收端也可以将其设置为0,表示已经确认了确认号-1个报文,但不希望再发送报文。这之后,接收端可以再发送相同确认号但非0的窗口大小告诉发送端继续发送。 校验和字段和UDP协议中一样,校验报头、数据和UDP一样的概念性质的伪头。这里校验是强制执行的。 选项字段可以用来增加报头没有提供的功能,但是必须以32bit=4字节为单位地增加,不足部分用0填充,最多可以扩展到报头达到40字节。
3.TCP协议传输的过程
相对于UDP即发即送,TCP的发送过程十分复杂,前文说到包括建立连接、发送数据、终止连接三个过程,现在逐一说明。
1.建立连接:三次握手
所谓三次握手,就是在建立连接的过程中会三次发送数据来建立连接。这里将主动建立连接的一方称为客户端,被动地等待建立连接的一方称为服务器,这个比方是参考HTTP协议即网页浏览的过程的。
- 第一次:客户端发送一个SYN=1,ACK=0的报文,这个报文的序号假设 为x。报文还包含了希望连接的服务器IP地址和端口号,可接受的最大TCP段长,还有其他的一些参数。目的TCP实体收到这个报文后会检查是否有进程监听了这个希望的端口,如果没有它会直接回复一个RST=1的报文,拒绝连接请求。
- 第二次:如果服务器端成功接收请求并且有进程在监听这个端口,那么TCP实体会转交这个报文。如果该进程接受请求,下一步他会发送一个确认段,其中SYN=1,ACK=1并且它的确认号为x+1,序号则不同,假设为y。
- 第三次:当客户端收到这个消息后,可以发送SYN=0,ACK=1的报文告知服务器已做好发送数据/接受数据的准备,这个报文的序号为x+1,确认号是y+1。至此,TCP连接已建立。
想象一下在两个山头,有这样两个人对话。
客户端:“嘿,有人吗,我想在那边的XX端口上建立通信。我的起始序号是x。”
服务端:“可以的,兄弟,我这里的序号是y。你什么时候开始?”
客户端:“好的,我们可以开始通信了。”
TCP连接建立的内容差不多就是这样。
这个图包含了单方向的连接过程,也包含了双端同时建立连接的过程。
当监听的进程在得到第一次握手的通信并回复第二步时,它必须记住自己发送的序号即y,因此存在了漏洞:一个客户端程序可以数次请求建立连接,在得到回应后却不确认建立连接,即不进行第三次握手,让服务端白白记住序号而占用资源,这被称为SYN泛洪。目前采用的改进方法避免了这个漏洞:服务器不记忆这个序号,而是选择一个加密产生的序号,将其放在回 复报文中并且不去记忆。在三次握手完成后,这个序号会被其接收到(虽然实际是序号+1),服务器可以采用原来的加密函数,和没有变更的加密输入(例如请求主机的IP地址和端口号,本地密钥等等),找到原来的正确的序号。注意这种办法无法处理选项字段,因此用于容易受到SYN泛洪的服务器。
2.发送数据:传送——确认
确认号是用来干什么的?前文说到TCP协议是面向字节的传输协议,它会纠正到以字节为单位的错误,如果有错误就要重新发送,这是基于确认号实现的。接收方在校验完数据后需要回复下一个希望收到的报文序号,即说明已确认无误的报文序号为这个序号-1,这在前文有说明。
假设这样的情况,接收方收到了正确的1,2,4,6号报文,这时应该回复序号1,确认号3,让发送方重新发送3号报文,因为3,5号报文丢失了。在收到3号确认无误后,会再回复序号2,确认号5,让发送发重发5号报文。集齐1~6号后,接收方才会发送序号3,确认号7。如果收到的5号报文出错,会再次发送一个序号3,确认号5的报文要求发送方再次重发5号 但这并不意味着发送方会一口气发送完所有报文,因为有着发送窗口限制;同时也不会一有数据就会发送一个报文,在发送方和接收方都有一定的缓存,用来暂存收到的数据,达到一定量或者一定时间后,才会发送出报文或者转交给进程,这样不会持续出现报头20字节,而数据只有几字节的情况,除非应用要求立即发出或者转交进程。
这样通过序号字段和确认号字段,TCP可以实现单方向的文件传输(如上传文件到网盘,下载内容),也可以实现双方向的通信(如网络游戏)。
3.终止连接:四次握手
TCP连接的终止不是单方向说了算的,需要两方同时确认才可以关闭。因此需要四个TCP报文才可以终止连接。 第一次握手:任何一方都可以提出终止的请求。提出方可以设置FIN=1,表示试图终止连接。 第二次握手:接收方如果允许,回复ACK=1,表示接收提出方的终止连接请求。 第三次握手:接收方再次发送报文,这次是FIN=1,表示请求终止连接请求。 第四次握手:类似第二次,提出方回复ACK=1,表示接收终止连接请求。 这样,这个TCP连接被释放,在此过程中的序号和确认号和建立连接和传送数据时是一样,逐次加1。此外,为了考虑到没有回复的情况,额外增加考虑了数据包的最大生存期的设定:比如请求方发送了FIN请求,但是对方一直没有响应(也有可能响应报文丢失了),那么请求方会终止连接。而另一方总会最终注意到对方不再有所反应,达到超时,也会主动终止连接。这也是迫不得已的做法。
四.网络编程——通信实践
这一部分讲述如何利用编程语言来进行网络通信,也就是所说的“socket 编程”。 什么是socket?socket就是“套接字”,是计算机通信中对终端的抽象,通过套接字,应用程序可以实现数据在网络上的读取、发送。如果你的程序想要通过网络进行数据交换,那么socket编程将是必要的一部分。这里先使用C语言来实现这些内容。
1.创建套接字
在这里我们将细说套接字的本质。如果你的C语言有一定基础,那么一定听说过“Unix的一切都是文件”这句话。事实上,套接字在Unix/Linux上的的确确就是文件描述符,一个整数,而交换的数据报文就可以看作是文件。你可以使用read(),write()函数来读取报文,但不推荐这样做,因为有更好的函数来使用。在Windows上,socket有自己的类型,所以没办法直接当作文件使用报文,必须使用相关的函数来读写。
套接字还有两种类型,对应前文中传输层的两种主要协议。一种是SOCK_STREAM,即流式套接字,它就像一条传送带把内容依次传输过去,因为它使用了TCP协议。另一种是SOCK_DGRAM,即数据报文套接字,它使用了UDP协议,只负责发送报文,不负责验证、重传等麻烦工作,因此可靠性较低。但这不意味着容易出错,因为下层也提供了纠错能力。使用这个应该考虑的是数据是否会依次序接收,如果你把一个大文件分割成几个报文发送,那么一定要考虑对方会不会按次序接收到,然后再按次序重新组装。
说了那么多,那么我们来快速创建一个socket函数。首先我们需要导入库,在Linux上添加这些:
#include <sys/types.h>
#include <sys/socket.h>
在Windows上则是这样的库:
#include <WinSock2.h>
#include <Windows.h>
注意!在Windows上请按这个顺序导入,因为Winsock2.h重新定义了Windows.h中的一些内容。此外,你还需要引入相关库文件,否则无法链接。可以在代码中加入:
#pragma comment(lib,"ws2_32.lib")
如果依然显示找不到相关函数,可以加入编译命令-lws2_32
。
因为函数的使用在两类平台没有较大差别,我选择了比较熟悉的Windows平台使用socket有关的函数。
SOCKET socket(int af, int type, int protocol)
这便是socket函数的定义,它将返回一个我们期待的一个套接字,有了它我们就可以进行网络通信。他包含三个参数,其中type便是之前提到的socket类型,你可以直接选择SOCK_STREAM或者SOCK_DGRAM作为参数。protocol表示传输协议,可以选择UDP或者TCP。然而事实上当你决定type时,就只能用TCP和UDP中的一种了,因为他们和socket类型几乎是绑定的关系,所以直接选择0让系统自己去选择合适的传输协议更好。 而参数af比较复杂一些。af其实是address family的简写,即地址族的意思,这个地址自然指IP地址,而地址主要使用的有两类:IPv4和IPv6,因此 这里的参数你可以选择AF_INET和AF_INET6,代表你在通信时使用的协议如何。
事实上,参数af其实改为pf,即protocol family,传递参数时用PF_INET更符合规范,因为这里是在定义IP协议,但是通常PF_INET和AF_INET是一样的,使用哪个都会产生相同的效果。
2.选择地址
在谈到参数af时,你有没有想到了一个问题:创建套接字是为了通信,可通信的目标又是谁呢?就好像写信要有对方的地址一样,进行网络通信得有目标的地址,也就是对象。现在来讲述如何确定通信的对象。 要确定通信的对象,我们使用IP地址+端口号的形式来确定,因此应该有结构来表示地址和端口号。Winsock中这样的地址结构定义如下:
typedef struct sockaddr_in {
#if(_WIN32_WINNT < 0x0600)
short sin_family;
#else //(_WIN32_WINNT < 0x0600)
ADDRESS_FAMILY sin_family;
#endif //(_WIN32_WINNT < 0x0600)
USHORT sin_port;
IN_ADDR sin_addr;
CHAR sin_zero[8];
} SOCKADDR_IN, *PSOCKADDR_IN;
这里共有四个内容。sin_family表示地址族,它的类型和Windows系统版本有关,但不影响我们使用,因为一般不会直接去操作这个。sin_port是端口号,sin_addr是地址,它内部其实是一个联合。
typedef struct in_addr {
union {
struct { UCHAR s_b1,s_b2,s_b3,s_b4; } S_un_b;
struct { USHORT s_w1,s_w2; } S_un_w;
ULONG S_addr;
} S_un;
//省略了一些与本文无关的定义
} IN_ADDR, *PIN_ADDR, FAR *LPIN_ADDR;
相当于可以直接访问到字节、字或者整个四字节的地址。下面的sin_zero数组是用来和SOCKADDR这个结构对其大小的,这个结构是最初定义地址的,不过使用SOCKADDR_IN更好。此外要注意里面的内容应该使用网络字节顺序,也就是大端法。为了防止网络字节顺序和本地机器的顺序不同而产生错误,我们在处理这个结构和发送、接收报文时要做到转换。转换的函数非常简单,如
u_short htons(u_short hostshort)
函数就可以将16字节的本地字节顺序内容转换成网络字节顺序。以此类推有转换32字节的htonl()函数,还有反过来讲网络字节顺序转换为本机字节序的ntohs()和ntohl()函数。
这样我们就可以来写我们想要的地址了。不要忘记通信是一个相互的过程,我们不仅要有目标的地址,也要有本地的地址,不然之后在通信时我们就只能发送,没法接收了。这里我们建立两个地址结构数据,一个表示本地,一个表示目的的服务器。假设都在双方的2048端口上进行通信。
SOCKADDR_IN localAddr,serverAddr;
memset(&localAddr, 0, sizeof(localAddr));
localAddr.sin_family=AF_INET;
localAddr.sin_addr.s_addr=INADDR_ANY;
localAddr.sin_port=htons(2048);
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family=AF_INET;
serverAddr.sin_addr.s_addr=inet_addr(192.168.1.105);
serverAddr.sin_port=htons(2048);
这里使用memset()函数是为了讲分配给的变量内容初始化为0,因为C语言不会在声明变量时做初始化。之后对s_addr和sin_port赋值即可,注意一定是网络字节序。 你一定注意到了我们赋给localAddr的地址是INADDR_ANY,这里IN代表Internet,ANY意味着任何地址,事实上这个宏定义是0.0.0.0。我们在这里是要想表示本机的IP地址,但是大部分情况时我们不知道本机的IP地址(比如一般家庭路由器会分配一个192.168.1.X的IP地址,而校园网又可能分配一个10.X.X.X的地址),甚至可能有多个IP地址(因为采用了多个网卡),因此用INADDR_ANY表示只要是发给本机的都会读取。而且这里也没有使用htonl()函数,因为它全是0,字节序如何不会影响使用。服务器的地址需要一个点分十进制的IP地址,这里inet_addr()函数可以将点分十进制IP地址直接转换成网络字节顺序。 有了地址和端口号,我们就有了和传输层打交道的“敲门砖”。那么该如何使用呢?
3.建立连接
这部分内容和我们采取的socket类型,或者说传输层的协议——TCP或是UDP有关。在正式读写报文前,我们应当建立起连接,好让系统知道我们要进行网络通信了(因为我们现在只得到了套接字,而没有正式绑定端口号)。 首先我们要处理本地收到的报文。要这样做应该采用bind()函数:
int bind(SOCKET s, const struct sockaddr *name, int namelen)
s就是我们通过socket()得到的套接字,name是指向本地地址 结构的指针,namelen就是本地地址的结构的大小。借助上文的localAddr,可以这样使用:
if(bind(sock, (SOCKADDR*)&localAddr, sizeof(localAddr)) == SOCKET_ERROR)
showError("bind() error");
sock是得到的套接字。要看到localAddr是SOCKADDR_IN结构的,我们要使用这个函数需要将他强制转换为SOCKADDR(也就是struct sockaddr),尽管内容没有发生变化。如果bind()函数在绑定的过程中失败,他会返回-1,而SOCET_ERROR这个宏定义其实便是-1。
接下来我们要连接目的机器。如果你使用UDP协议/SOCK_DGRAM,那么可以直接跳到下一节了,因为可以直接根据地址和端口号发送报文。如果你使用另一种,那么还需要connect()函数来连接目的机器:
int connect(SOCKET s, const struct sockaddr *name, int namelen)
这和使用bind()是相同的,不过要注意绑定的是目的主机。如果连接失败,它将返回-1。然而问题还没结束:目的机器怎么和我们这边顺利连接?为此我们来解决这个问题。 首先目的机器,或者说服务器,首先要处于一个等待被连接的状态。需要使用listen()函数来告诉它的系统在等待来连接:
int listen(SOCKET s, int backlog)
这里s是socket()创建的套接字,并且用bind()绑定了端口。backlog是一个队列长度,表示最多有多少个连接在等待被你接受,通常这个值可以设为5或10。接下来是比较麻烦的:
SOCKET accept(SOCKET s, struct sockaddr *addr, int *addrlen)
需要这个函数来接受远程机器发来的连接请求。s必须是我们通过上一步listen()的套接字。注意这个函数返回的不是简单的int,而是套接字,如果连接成功,请求连接机器的地址便会写在参数*addr里,第三个参数自然是 其长度。在完成这一步函数后,相当于你现在拥有了两个套接字:原来的套接字还在用来监听端口,新创建的套接字要用来和这个机器进行通信。如果连接失败(比如当前没有连接请求),它会返回-1。 总结一下这部分内容:bind()用来监听本地端口有无接收到数据。如果使用TCP协议/SOCK_DSTREAM,还要再用listen()等待连接请求,accept()完成连接,用connect()发起连接请求。那么服务器的使用顺序为:bind()→listen()→accept(),客户端的顺序为:(bind())→connect()。使用TCP协议的话,客户端不需要bind(),在连接成功就可以读取报文了。
4.收发报文
终于到了激动人心的时刻,我们终于可以收发报文了。首先来看如何在TCP协议下读写:
int recv(SOCKET s, void *buf, int len, int flags); //读取报文
int send(SOCKET s, const void *buf, int len, int flags); //发送报文
这里的s是所使用的套接字,如果用accept()得到了新的套接字,那么应当用这个。buf指向一片缓冲区,从这里读取你要发送的数据,len是数据的长度。flags设置为0即可。这两个函数都会返回发送/接收的字节数,如果返回-1那么表明发生了错误。注意send()函数发送的字节数可能会比你发送的字节数小,因为系统有自己的缓冲区,这时系统希望你继续发送新的数据,它会一并发送的。
接下来来看UDP协议如何发送/接收:
int recvfrom(SOCKET s, char *buf, int len, int flags,
struct sockaddr *from, int *fromlen); //读取报文
int sendto(SOCKET s, const char *buf, int len, int flags,
const struct sockaddr *to, int tolen); //发送报文
这里前四个参数与之前一样,新加入的参数则是接收/发送的地址和相应结构的长度,类似于使用bind()时那样,不要忘记将SOCKADDR_IN转换为SOCKADDR。 这样我们便可以将获得的信息直接在内存中编写,或者将内存中的内容发送了。不过要注意,如果你想要读取其他服务的报文,例如DNS报文等,你需要考虑转换网络字节顺序为本地字节顺序,当然发送也是一样的。如果你是编写了两个程序通过网络进行通信,内容也是自己的内容,尽管转换为网络字节顺序更规范些,但你可以跳过这一步,只要两台机器采用一样的字节顺序就行。
事实上,用UDP协议时,可以用connect()函数取连接服务器,然后再利用send()和recv()函数读写报文。这样做相当于系统帮你记住了地址和端口号,在读写时自动添加。
5.关闭连接
UDP协议不会建立连接,因此发完报文就可以停止程序了。如果是TCP协议,还需要停止连接:
int closesocket(SOCKET s); //Windows
int close (int sock); //Linux
这样双方的读写操作都不能进行了。也可以选择shutdown()函数:
int shutdown(SOCKET s, int how); //Windows
int shutdown(int sockfd, int how); //Linux
/*
how:
0——不允许对方接收
1——不允许本地发送
2——不允许发送和接受(和close()相同)
*/
这样一个TCP连接就此结束了。而socket编程的主体内容就是这些,有了这些知识,你便可以编写程序,在两台电脑上进行你需要的网络数据交互。
参考资料:
《计算机网络》第五版,Andrew S. Tanenbaum & David J.Wetherall
《TCP和UDP的区别》
《C语言SOCKET编程指南》
《socket编程(C++)》