原文地址 http://yriuns.github.io/2017/01/23/http-procedure/
当你在浏览器中键入网址(不妨假设为www.example.com,这个网站非常简单,只有一个HTML文件)并按下回车,就能看到渲染出来的网页。但是,这一过程中具体发生了什么呢?
这个问题困扰了笔者多年,虽然知道数据终将转为01比特流,但却一直没想通具体是怎么传输的……终于今年学了计算机网络原理,虽然没好好听课,而且网上看到的文章感觉写的还不够全面,于是试图自己把这个事讲明白。
本文力图以上面所说的例子,按照TCP/IP参考模型来对该过程进行分析。
TCP/IP简介
首先简单的介绍一下TCP/IP参考模型。众所周知, 它是一个分层模型,同层之间通过“协议”来约定数据包的格式,上层通过“接口”来使用下层提供的“服务”进行运作。
举个例子。A公司从上到下有三层:老板、秘书、员工,B公司也是如此。且老板会中文和英文,秘书会英文和日语,员工会日语和韩语。然后呢一天A老板有一份中文文件要给B老板,但是老板毕竟不能亲自跑一趟,于是他把文件给了A秘书。但是A秘书不认识B老板,只知道B秘书的办公室在哪。于是A秘书又用日语告诉A员工把文件送到B秘书的办公室。那A员工又不是B公司的人,肯定不能直接到办公室找人,于是A员工只是跑到B公司然后把文件给了B员工。接着B员工转给了B秘书,B秘书终于把文件给了B老板。
在这个例子里,中文文件就相当于两个老板之间交流沟通的“协议”,上级给下级布置任务的各种语言就是“接口”,下级能完成的任务就是“服务”。
还有一点比较重要的是:
真正的累活是员工A和B完成的,其他人只要布置任务就行了。
具体到TCP/IP协议呢,它分为4层:
- 应用层(Application Layer),对应于OSI七层模型中的会话层、表现层、应用层。这一层是用户具体能感知到的一层,比如浏览器啦、游戏客户端啊什么的。
- 传输层(Transport Layer),对应于OSI中的传输层。这一层为应用层提供面向连接的服务TCP协议,和无连接的服务UDP协议。
- 网络层(Internet Layer),对应于OSI中的网络层(Network Layer)。解决的问题是如何把多条链路结合为网络,从而使并不直接相连的两台机器之间也能发送消息。
- 主机-网络层(Host-to-network Layer),对应于OSI中的数据链路层(Data Link Layer)与物理层(Physical Layer)。
- 数据链路层关注的是如何在两台直接相连的终端上发送消息。
- 物理层的功能是在物理线路上传输二进制比特流。
为了方便,本文采用5层模型,即:应用层、传输层、网络层、数据链路层、物理层。
概述
为了方便,称客户端为A,服务端为B。
总体上,大致可分为如下步骤:
- 接入网络。为了完整性,我将这一步也列在这里。毕竟是所有步骤的前提,而且这一步也不简单。
- 域名解析,得到www.example.com的ip地址。
- 浏览器(应用层)通过传输层提供的服务建立起连接(TCP三次握手)。
- 传输层将应用层的数据加上协议头以“数据段”为单位发给网络层。
- 网络层将传输层的一个“数据段”包装为一组或多组(取决于数据长度),每一组都加上协议头,变为多个“数据包”,通过数据链路层进行分组转发(每个分组可能具有完全不同的转发路径),比如某个分组的路径是:A->路由器0->路由器a1->…->路由器an->B;而另一个分组则是:A->路由器0->路由器b1->…->路由器bm->B。
- 数据链路层网络层的包加上帧头帧尾,组成“数据帧”,以帧为单位通过物理层传输。
- 数据帧经过物理线路的传输,到达了服务端的物理层,接下来物理层传给数据链路层,数据链路层去掉帧头帧尾后又传给网络层。网络层将所有分组收集完毕后,分别去掉协议头然后重组为数据段传给传输层。传输层去掉协议头传给浏览器。
- 浏览器向服务器发起HTTP请求。
- 服务器响应HTTP请求,将HTML返回。
- 浏览器得到HTML。
- TCP四次挥手关闭连接。
接下来就进行详细的介绍。考虑到理解方便,我们将以自底向上的顺序先介绍各个层,再介绍上面的例子。因此本文的顺序是这样的:
然后是传输层与HTTP请求的介绍:
不想看层模型介绍的同学可以直接点这里。
物理层
物理层是数据传输的基础。
数据通信原理
假设我们要发送一个ASCII字符b
,首先我们将其转为8比特长的字节,也就是01100010
,接着我们输出了LHHLLLHL
的电信号(0对应L
,代表低电平,1对应H
,代表高电平)。也就是下面这个方波。
这时候,第一个问题出现了:由于物理原因,线路存在截止频率f,会导致波形失真。形象的说就是方波不方了。
那么,具体是怎么影响的呢,我们方方正正的波形传播之后会变成啥样嘞?傅里叶告诉我们,任何一个周期为T的函数都可以表示为正弦函数和余弦函数组成的无穷级数。我们把刚刚的方波看成一个以T为周期的周期函数的一部分,然后做一个傅里叶变换(取前8项)。大概长这个样子:
还是可以认出来谁是0谁是1的,只要阈值取的好,可以很容易的还原成原来的方波。而截止频率则是说:存在一个截止频率,使得该频率以下的信号,振幅在传输过程中不会衰减;该频率以上的信号,其振幅会出现不同程度的减弱。考虑一个极端情况,截止频率为上图谐波3对应的频率,且大于截止频率的信号在传输过程中将被滤掉、振幅为0,即只剩下了谐波1和谐波2,然后信号变成了这个样子:
Quick Note
实际上,截止频率并不是尖锐的,振幅的略微衰减也是可以接受的。如果把能量(振幅的平方)衰减为原来的1/2时的频率记为f的话,我们(电气领域)称0Hz至f这一段频率的宽度称为带宽。而计算机领域一般把信道的最大传输速率(bits/s, bps)称作带宽。
这就很尴尬了…第3个1只剩这么一点了。这就是截止频率的影响……本来棱角分明直的不行的方波就这么弯了。可见,截止频率越低,能够“存活”下来的谐波数就越少,叠加之后的波形也越不像原始的方波。按照刚才的假设,我们的方波周期为T,因此1/T为其频率,也是基频的频率,而能存活下来的谐波数满足 n*1/T<f,也就是n<fT。T可以通过比特率来算,如果比特率为b bits/s,那么发送8bits(1byte)需要的时间为8/b,同时这也是T的值。所以我们得到n<f*8/b。对于一条普通电话线,它的截止频率大概为3000Hz,于是有n<24000/b,也就是说,如果我们想让波形为前8项,那么比特率不能超过3000bits/s。如果我们要以9600bps的速率发送数据,那么传输的信号实际上长得和前2项差不多。
Quick Note
实际上最大传输速率并不是由刚刚的不等式b<8f/n决定的。因为我们没有考虑采样率,也没有考虑噪声等其他因素。一般用这俩定理:尼奎斯特定理&香农定理来计算最大传输率。
数字调制
基带传输
假设我们经过尝试,找到了一种合适的线材,也找到了合适的比特率。那么我们还将面临一个问题:电压信号是连续的,如果你在1s内发了6个0:000000
,在我看来只不过是这1s内全是低电平而已,那我怎么知道你发的是6个0而不是5个0或者7个0呢……?
解决这个问题有很多办法:
- 约定一个采样频率。但这需要我们拥有一个很准很准的时钟,否则迟早会出现错位。
- 改变信号发送的方式。机智的前人们想出了一个方法:信号从
L
到H
的跳变代表0,信号从H
到L
的跳变表示1(例子见下图中的d)。因此0000
就变为了LHLHLHLH
,接收方很容易能将其还原为0000
,而不再会纠结到底是几个0。这个方案叫做曼彻斯特编码。然而这种方案的缺点显而易见:传输同样的数据需要原来2倍的带宽。因此,还有一些其他的编码方式试图解决这100%带宽额外开销的问题,在此不作详述。
通带传输
对于无线传输来说,通常不可能使用0~截止频率的频率段来发送信号(基带传输),因为低频意味着波长长,意味着天线也将很大。于是,诞生了通带传输,简单地说就是将0~B Hz的信号搬到S~S+B Hz的频段上。
频段变了,表示01的方式自然也要变(按原来傅里叶的方法来叠加显然是不对的……)。常见的有:(a) 幅移键控,即某个振幅表示0,另一个振幅表示1;(b) 频移键控,即某个频率表示0,另一个频率表示1;(c) 相移键控,即某个相位角表示0,另一个相位角表示1。详见下图:
数据链路层
解决了01比特的传输问题后,接下来要解决的是:如何在两台通过信道连接的机器之间传输完整的信息块(我们一般称为数据帧)?你可能会说:这不是很简单嘛,A把比特放上去,B把比特取下来呗。然而,B咋知道什么时候取下来呢……而且,线路是会出错的,也许由于某些外界刺激0变成了1,1变成了0,那我们如何知道到底出错了没?另外,信道的传输速率是有限的,信号的传播也是需要时间的。这些问题都是数据链路层需要解决的。
构造数据帧
对于物理层来说,它能为数据链路层做的只是提供原始的比特流。那么,怎么判断一段比特流到底属于一条消息(帧)还是多条消息呢?如果是后者,该在哪些位置断开?常见的方法有:
- 字节计数法:也就是利用头部的一个字段来标识该帧含有的字节数。帧结构大概长这样:
|字节数|字节1|字节2|...|字节n|
- 标志字节法:即用约定好的特殊字节
FLAG
来标志帧的开始和结束。类似于这样:|FLAG|数据域|FLAG|
。不过这样有一个问题……如果数据域里有FLAG
怎么办?学过编程的应该都知道转义字符的存在,这里也是类似的,使用转义字节ESC
来对数据域中的FLAG
转义。那如果数据域中有ESC
呢?……也用一个ESC
转义就好了。现在它大概长这样:|FLAG|XXXX|ESC|ESC|XXXX|ESC|FLAG|XXXX|FLAG|
,容易看出来实际上的数据是|XXXX|ESC|FLAG|XXXX|
。 - 比特填充法:该方法规定:以
01111110
作为帧的开始和结束,同时数据域中需要在连续的5个1后插入一个0。即如果你想发送111111
,那么你将发送|01111110|1111101|01111110|
。
现代网络一般综合使用了这些方法。比如以太网采用了一个很长(8bytes)的标志字节作为帧的开始(称为前导码),又在前导码后有一个长度字段(字节计数)来定位帧的结束。
差错控制
前面说到,线路是会出错的。因此人们想出了不少办法来处理传输错误,不过它们基本可分为两种策略:
- 纠错码。即在发送的数据块中加入足够的冗余信息,使得即使发生了传输错误,接收方也能恢复出正确的数据。有点类似二维码,把某些地方涂黑依然能扫出来。
- 检错码。同样加入一些冗余信息,但接收方只能推断出是否发生了错误。
对于有线信道,出错率很低,一般采用检错码。当错误发生时,只要重传就好了,毕竟只是偶尔出错。
而对于无线信道,出错率可能比前者高了几个数量级,如果采用检错码,会导致频繁的重传,而且重传的数据依然很可能出错,又需要重传,给信道带来极大的负担。因此无线信道一般采用纠错码。
滑动窗口协议
显然,对于出错率不可忽略的信道来说,数据帧的传输是需要接收方确认的,否则发送方无法知道此次传输是否成功。那么如何做好这件事呢?
我们容易想到一个naive的想法:一帧一帧的发,接收方收到一个正确的帧,就返回一个确认(一般称为ACK,acknowledge)说:上个帧收到了,没问题,发下一个帧吧!然后接收方收到了这个ACK,就继续发送下一帧。万一,这个数据帧在半路丢了,发送方收不到ACK咋办?甚至说发送方都不知道这个帧丢了。我们可以弄一个计时器,比如1s,如果1s内没收到ACK,那发送方就认为数据帧丢了,重新发送这个帧。如果是ACK在半路丢了呢?那发送方同样收不到ACK,同样会进行重传,但是接收方已经收过这个帧了,它应该把这个帧丢弃,并且返回一个ACK。这就需要给数据帧一个序号,否则接收方并不知道这是一个重复的帧,还是两个内容一样的帧。
这个协议就叫做1bit滑动窗口协议。
不难发现,这个方法是极其低效的……不妨做这么一个计算:假设比特率为b,传播延迟为t,数据帧大小为d,那么这个方法的效率是
也就是说,比特率越高、传播延迟越大,效率越低,这显然不是人类希望的结果。于是,诞生了回退n帧协议。
它的基本思想就是:发送方一次可以发送多个帧,但要根据计时器重传超时的帧(及其之后的帧)。接收方只接受序号正确的帧,也只对序号正确的帧返回ACK。
具体来说呢,发送方开辟一个最大尺寸为n(不妨设为5)的窗口,记录着所有“已发送但未确认”的帧,并且为其中每个帧维护一个计时器。此外,窗口拥有下界和上界,分别是窗口中的帧序号的最小值与最大值。当某个帧超时,重传窗口内所有的帧,并且刷新计时器。当收到ACK时,如果ACK的值等于下界,就将下界+1(即从窗口中删掉第一帧)。另外,只要窗口尺寸不超过n,发送方都可以发送新的帧。
接收方则简单的记录一个“下一个希望接收的帧序号k”即可。
举个例子:发送方有7个帧(0123456)要发。初始阶段,发送方窗口为空,接收方k=0。接着发送方连续发了5个帧,发送方下界为0,上界为4。接收方收到了0和1,并且分别返回了两个ACK,发送方下界为2,接收方k=2。发送方又发了5和6,上界变为6。结果呢2号帧丢了,3和4没丢。那么由于k=2,收到的却是3和4,因此接收方不返回ACK。发送方的计时器超时后,重发了2、3、4、5、6帧。这回很顺利,5个帧都没丢,接收方分别返回了5个ACK,传输结束。
实际上,该协议允许累计确认,也就是ACK=m代表0-m帧均接收成功。对于上面的例子,发送方可以只发送ACK=1,代表它收到了0和1。发送ACK=6,代表它收到了2、3、4、5、6。接收方的窗口更新机制变为:只要ACK落在下界和上界之间,就将下界设为ACK+1。
这个协议有一个明显的缺点:一旦超时,需要重传的帧太多了。一个很自然的想法就是只重传需要重传的帧,于是又诞生了选择重传协议。不过在此不做介绍。
单工、半双工与全双工
- 单工:信息只能单向传输,即A可以给B发数据但B不能给A发(不过B可以发送监视信号)。
- 半双工:信息可以双向传输,但在某一时刻只能单向传输,类似于对讲机。
- 全双工:信息可以同时双向传输,类似于电话,两个人可以同时说话(虽然那样会听不清)。
介质访问控制子层
了解了两方参与的协议之后,我们来看看多方协调的协议,也就是确定“多个用户使用同一个信道时,某一时刻轮到谁”的问题。该层称为介质访问控制子层(Medium Access Control Sublayer, MAC),属于数据链路层的一部分,位于它的底部。
不管是无线网络还是有线网络,都存在信道干扰的问题。对于前者,工作在同一个频段的不同WiFi,或是连接到同一个WiFi的不同设备,它们之间都存在着信道干扰。对于后者,单根电缆或者光纤连接着多个节点,也存在着信道干扰。因此,如何分配信道使得各个用户都能正常使用成了一个必须解决的问题。
以太网
首先来看看有线网络是如何处理的。以太网可能是如今最普遍的计算机网络了。它的帧格式长这样:
其中(a)是以太网帧,(b)是IEEE 802.3的帧。不过它俩几乎一样,所以大家经常把它俩当做一个东西。
首先是8个字节的前导码,接着是6个字节的目的地址和6个字节的源地址,这俩地址也就是我们经常听说的MAC地址。接着是2个字节的类型/长度字段。然后是最长为1500字节的数据域。然后是填充字段,它是当数据域部分太短(小于46字节)时,用来填充该帧的,使其达到最小长度要求。最后是校验和,提供检错功能,即如果接收方检测到一个错误就丢弃该帧。
曾经,以太网采用的方案是:CSMA/CD算法。简单来说就是采用半双工方式,发送方会监听介质(电缆),一旦介质变为空闲就开始发送。如果在发送的过程中检测到冲突,立即停止传输,并发出一个警告信号通知其他机器有冲突发生,所有机器都等待一段时间后重发。这个等待时间由二进制指数后退来决定,也就是:第一次冲突后,每个站随机等待0个或1个时间槽。第二次冲突后,每个站随机选择0、1、2、3个时间槽,以此类推。当然还有一个最大的选择区间,为1023。这也被称为经典以太网。
后来,人们发明了交换机。它的结构大概是这样:
其中电缆(图中的line)是全双工的,接在交换机的端口(port)上,交换机只用把帧从入端口输送到帧想去的对应的出端口就行了。这么一改不得了了,没冲突了啊:由于电缆是全双工的,所以机器和端口之间可以同时收发数据,不存在冲突;如果出现两个帧都想走同一个输出端口的情况,交换机只要设一个缓存,逐帧发送即可,也不存在冲突。
非常重要的一点是:
由于有线电缆出错率低,因此以太网不使用确认机制。即只要没有冲突发生,发送方就认为发送成功。 如果确实有错误发生,那么由高层负责处理。而且,下面要说的无线局域网也不采用确认机制。也就是说:如今,确认机制/滑动窗口协议并不在数据链路层使用,而是在高层(传输层)使用。
无线局域网
无线局域网主要采用IEEE 802.11标准(然而内含802.11a、802.11b、802.11g等不同方案……)。与有线网络不同,无线网天生存在劣势:不同设备发出的电磁波在空间中会互相叠加互相干扰。那么,如何解决呢?
801.11b
虽然801.11a小组先成立,但是801.11b标准先获得了批准,因此我们先来看看801.11b。它基于码分复用多址(Code Division Multiple Access,CDMA)。它采用了一些数学技巧使得用户可以同时发送数据。在前面的物理层部分我们提到,曼彻斯特编码用10表示1,01表示0。CDMA则不一样,每台机器对于0和1的表示互不相同。举个例子,A使用A=(-1 -1 -1 +1 +1 -1 +1 +1)
来表示1,用它的反码-A=(+1 +1 +1 -1 -1 +1 -1 -1)
来表示0。B使用B=(-1 -1 +1 -1 +1 +1 +1 -1)
来表示1,-B=(+1 +1 -1 +1 -1 -1 -1 +1)
来表示0。C使用C=(-1 +1 -1 +1 +1 +1 -1 -1)
来表示1,-C=(+1 -1 +1 -1 -1 -1 +1 +1)
来表示0,这些数字序列被称为码片。接着来看看它们同时传播会怎么样,比如A想发送xx11
,B想发送x100
,C想发送11x1
。x代表不发送。对于第一位只存在C的信号,总信号为S1 = C = (-1 +1 -1 +1 +1 +1 -1 -1)
对于第二位,B和C的信号在空间中叠加,总信号为S2 = B + C = (-2 0 0 0 +2 +2 0 -2)
第三位S3 = A + (-B) = (0 0 -2 +2 0 -2 0 +2)
第四位S4 = A + (-B) + C = (-1 +1 -3 +3 +1 -1 -1 +1)
接下来就是见证数学神奇的时刻。细心的读者可能会发现,如果把ABC看做向量,那么它们是两两正交的A · B = A · C = B · C = 0
拥有这个性质之后,对于接收方,它只要把发送方的码片与总信号做一个内积即可。比如想接收C的信号:S1 · C = 1
,S2 · C = 1
,S3 · C = 0
,S4 · C = 1
。( ⊙ o ⊙ )啊!我们得到了1101
,也就是11x1
(结果中的1表示比特1,0表示空,-1表示比特0)。
当然,为了方便说明原理,这个例子做了一个假设:各个机器的输出是同步的。
801.11a
801.11a基于正交频分复用(Orthogonal Frequency Division Multiplexing, OFDM)。主要思想是:。另外801.11a工作在5GHz频段,干扰较少。
801.11g
801.11g同样基于OFDM,只不过工作在2.4GHz频段。带来的好处是保持了802.11a的高速率并且兼容802.11b的工作频段。
网络层
经过之前的努力,我们已经能做到:将帧从线路的一边传送到另一边。现在我们有了一个更宏大的目标:从网络的一端传送到另一端。
Quick Note
- 点到点与端到端:点到点指的是直接相连的两台主机(比如A-B)之间的数据传输;端到端则是指逻辑上相连(物理上不相连,比如A-B-C中的A与C)的两台主机之间的数据传输。或者说端到端连接是由多个点到点连接拼接而成的。
- 我们把一个数据包从发送方到接收方经过的每一个路由器称为“跳”。
但是,此前我们传输数据依赖的标识是MAC地址,而MAC地址是和硬件绑定的,和网络无关。我们最好有一个与网络拓扑相关的标识,这样方便路由,这个标识也就是我们熟知的IP地址。
那么,IP地址具体是怎么使用的呢?或者说,任给两个IP地址,如何使它俩之间能够互相传输数据?在解答这个问题之前,需要先介绍一些背景知识。
数据报
这应该是整个Internet的基石了。数据报的意思是:如果A想给B发数据,不需要提前找到由A到B的路径,也不需要提前打招呼,而是直接向网络中发送数据包,由网络中的路由器为其逐跳转发,因此每一个数据包经过的路径可能是很不一样的。
与数据报相对的是虚电路:在数据传输开始前,找到一条由A到B的传输路径,接着该连接的所有数据包都沿着这条路径前进。
路由表
路由器里都会有一个叫做“路由表”的东西,其中的每一项都至少记录着如下信息:
- 目标IP地址
- 子网掩码,下文会说
- 下一跳IP地址
所以呢,当主机A通过链路层给路由器发了一个以太网帧时,路由器首先会去检查帧的目的MAC地址,如果是路由器自己,那么就会开始处理:
- 将以太网帧解封装,得到IP报文。
- 在路由表中搜索IP报文中的目的IP地址,如果找到,就会得到下一跳地址,接着再通过ARP协议得到下一跳的MAC地址,最后将IP报文再次封装为以太网帧进行转发。
- 如果没找到,将下一跳地址设置为默认路由,得到默认路由的MAC地址后同样封装为以太网帧进行转发。
ARP
ARP(Address Resolution Protocol),也就是地址解析协议,用来完成IP地址到MAC地址的映射。我们用下图所示的例子来讲解它的工作过程,这是由一个路由器相连的两个局域网:CS网络和EE网络。
假设CS网络的Host1想给CS网络Host2(192.32.65.5
)发送数据,那么首先Host1发送一个广播包到网络上,包内容是“谁拥有192.32.65.5
这个IP啊?”,然后Host1所在的局域网的所有主机(图中只画出了Host1与Host2)都收到了这个广播包,并且检查自己的IP地址。但是只有Host2会对此做出应答,应答内容就是自己的MAC地址。这样Host1就获得了Host2的MAC地址。
网关
目前,我们解决了:在同一个子网内的主机之间互相传输数据包。那么,不同子网内的两台主机,又应该怎么进行通信呢?还是以上图为例子,不过现在CS网络的Host1想给EE网络Host4(192.32.63.8
)发送数据。
从图上容易看出Host1和Host2不在同一个子网内,但是机器是怎么知道的呢?这里就要引入子网掩码这个概念。
子网掩码
子网掩码是一系列形如111111110000....000
(共32位4个字节,1只可能出现在高位)的数字,我们常见的子网掩码一般长这样255.255.255.0
、255.255.0.0
,这是将4个字节中的每个转为十进制再加上一个.
而来的。它的作用就是与IP地址做“与”运算,得到的结果可以认为是某个子网的标识。比如上面的例子中子网掩码为255.255.255.0
,把Host1、Host2的IP分别和它做与运算:192.32.65.7 AND 255.255.255.0 = 192.32.65.0
192.32.65.5 AND 255.255.255.0 = 192.32.65.0
意味着Host1和Host2在同一个子网。但是,对于Host4:192.32.63.8 AND 255.255.255.0 = 192.32.63.0
因此Host1和Host4不在一个子网里。
现在,Host1明白Host4和它不在一个子网内,那就得通过网关来帮Host1做中间的桥梁。
Quick Note
在传统TCP/IP术语中,网络设备只分成两种,一种为主机,另一只就是网关(gateway)。网关的作用是:如果目的地址在同一个子网内,发给对应的主机,如果不在,转给路由表中定义好的下一跳。
与先前类似,Host1知道网关的IP地址198.32.65.1
(一般网关拥有最低地址),但不知道它的MAC地址,因此同样通过ARP请求来获取。接着Host1将IP包封装为以太网帧发送至网关,网关再将其发送到EE网络,接着再转发给Host4。
一个更复杂的例子
我们考虑这样一个情景:A是北京
某排名第二的大学
的大学生,他买了一个路由器,并且利用网线将路由器和寝室的网口相连,接上了宿舍区的校园网,接着他的电脑、手机都连上了这个路由器的WiFi。
在这个过程中,路由器、电脑、手机构成了一个子网,且该子网通过路由器与校园网相连。他的路由器会分配到一个公网IP,就假设是162.105.44.20
吧,同时路由器还会有一个内网IP,比如是192.168.31.1
,这个IP同时也是A这个局域网的网关IP。另外由于路由器连接在校园网中,校园网也有一个它的网关IP,假设是162.105.44.1
吧。然后A的两台设备各会分到一个内网IP,假设分别是192.168.31.20
和192.168.31.30
。
这时候,如果电脑想给手机传数据,就类似于上面那个例子中Host1给Host2的情景。
如果A想给楼里另一个同学B(162.105.44.30
)传数据的话呢,他的电脑会把数据帧发给网关(192.168.31.1
),接着路由器再发给网关(162.105.44.1
),然后终于发到了B(162.105.44.30
)。
NAT技术
你可能会问,A只有一个公网IP162.105.44.20
,但是有两台设备,那为啥数据包不会乱套呢,毕竟服务器只用给162.105.44.20
发数据就行了,路由器怎么知道应该转给手机还是电脑?这就是由NAT技术搞定的了。
NAT技术的出现是因为IP地址不够用了,因此人们希望只用给每个家庭/公司一个(少量)IP地址,但仍然保证他们都可以接入互联网。在客户网络内部,每台主机具有唯一的IP地址——私有地址,比如192.168.x.x
,10.x.x.x
之类的。这个地址可以用来路由内部流量。但是,当数据包需要发向其他网络时,需要经过一个叫做NAT盒子的东西(路由器也实现了这个功能)。NAT盒子会修改IP包头部的源地址,修改后的值是公网IP。此外,NAT盒子还将修改TCP或UDP头中的源端口(一个16位的整数,下面会说),修改后的值是另一个16位的整数,但它代表的不是端口号,而是一个索引值。也就是说NAT盒子内部维护了一个16位整数到IP地址+端口号的映射。这么做的原因是各个主机可能使用了同一个端口(比如80端口),那么我们就需要类似于下表的一个映射表。
索引值 (同时也是修改后的 TCP头部的源端口) | IP地址 | 端口号 |
---|---|---|
12345 |
192.168.1.20 |
80 |
23456 |
192.168.1.30 |
80 |
传输层
经过不懈的努力,我们理论上已经能使地球上任意两台主机互相发送数据了。用专业一点的说法是:我们已经能提供端到端的数据传输服务了。而传输层将数据传输服务从两台主机之间扩展到了两台主机的进程之间。在这一层,数据的单位称为“段”
端口
刚刚说了,传输层提供的是进程之间的数据传输服务。但是,IP地址只有一个,进程却会有很多个,该如何实现这一目标呢?显然我们不会通过进程名来实现。“服务器要和162.105.44.20
的Google Chrome
建立连接”,这样既麻烦也不现实。真正的解决方案是使用端口。
当某个进程想与另一个主机建立连接时,它不仅要指定对端的IP地址,还要指定对端的端口号(比如80端口),并且告诉对端自己的IP地址和端口号。而对端主机则需要有一个进程监听对应的端口(80端口)。这样,当对端的进程收到连接请求时,就能获取到请求者的IP地址和端口号。换句话说,双方的两个进程通过两个端口号进行数据传输。
UDP
UDP协议非常简单,简单到基本上就是端口信息+数据,然后就转给了网络层形成IP包。它的头部长这样:
分别是:源端口、目的端口、UDP数据段的长度、UDP校验和。UDP协议是一种无连接协议,也不提供可靠传输服务,校验和的作用也仅仅是——如果校验和不正确就将该包丢弃而已。
TCP
UDP是如此简单以至于它提供的服务显得有点不靠谱……万一丢包了传输层甚至完全发现不了。而TCP协议解决了这一问题:提供面向连接的可靠传输服务。
TCP header
它的头部长这样:
是不是复杂了很多……我们先来介绍一下各个字段的用处。以A,B
互相发送为例。由于TCP是全双工协议,所以A
和B
有着各自的序号和确认号。
- 源端口和目的端口:意义和UDP相同。
- 序号和确认号:和数据链路层类似。在一次TCP连接中,可以多次发送数据,所以需要一个序号来充当该数据段在此次连接中的标识。确认号使用累计确认,则意味着某个数据段及其以前的数据段被接收方成功收到。但是,它的值为“下一个期望收到的数据段序号”。
- TCP头长度:因为TCP头的选项字段是变长的,所以TCP头也是变长的,所以需要标明TCP头长度,从而接收方能正确的将数据段分割为
TCP头|数据
。 - 4个暂时没卵用的比特位。
- 8个1比特的标志位:
CWR
:A(B)
将该位置为1意味着A(B)
已经降低了发送速率。ECE
:当A
希望B
降低发送速率时,将该位置为1。URG
:如果使用了紧急指针,则置为1。ACK
:ACK=1
意味着上面介绍的确认号字段有效。等于0则意味着无效。PSH
:A(B)
的传输层可能不会一收到数据段就传给应用层,而是在攒了一些数据段后再传给应用层。而如果PSH=1
则是A(B)
请求B(A)
的传输层一收到数据就传给应用程序。RST
:RST
用来重置一个连接,也被用于拒绝连接。SYN
:SYN
被用于建立连接的过程。FIN
:A(B)
令FIN=1
意味着A(B)
已经没有数据要传输了。
- 窗口大小:滑动窗口大小,该字段的值是可变的。
A(B)
将该字段置为某个非负数X
意味着A(B)
还能收X
个字节的数据。 - 校验和。
- 紧急指针:紧急指针是一个正的偏移量,和序号字段中的值相加表示紧急数据最后一个字节的序号。一般很少用到。
- 选项:选项域的长度必须为32位(4字节)的倍数,最长为40字节常用的选项有:
- 最大段长:
A
和B
可以互相告知自己能处理的最大段长,默认值为556字节。 - 时间戳:如果在连接建立阶段使用了这个选项,那么之后的所有数据包都要包含这个选项。主要作用是用来计算来回所需的时间;还可以用来防止序号回绕。
- 选择确认:
A(B)
可以告诉B(A)
它已经收到的序号范围,从而B(A)
可以知道应该重传哪些数据。
- 最大段长:
接下来我们就来看看TCP是怎么工作的。
TCP建立连接
TCP使用三次握手来建立连接。
- 首先服务端应当监听某个端口。
- 第一次握手:客户端向该端口发送
[SYN] Seq=x
,即标志位中SYN=1
(ACK=0
),且数据段序号为x
。 - 第二次握手:服务端如果接受这个请求,就发回一个
[SYN, ACK] Seq=y Ack=x+1
。y
是服务端数据段的序号,且希望收到客户端序号为x
的数据段。 - 第三次握手:客户端发送
[ACK] Seq=x+1 Ack=y+1
。x+1
即意味着是客户端的第2个数据段,y+1
意味着客户端确认了y
的收到,同时希望收到y+1
数据段。
TCP释放连接
一般来说,释放连接需要4个TCP段:
- 其中一端
A
发送FIN
。 - 对端
B
收到FIN
后发送ACK
。 B
发送FIN
A
收到FIN
后发送ACK
,然后释放连接。B
收到ACK
后释放连接。
这也就是我们常说的4次挥手。不过B
的ACK
和FIN
可能被一起发送,从而只需要发送3个TCP段。
这里存在一个很重要的问题:万一丢包怎么办?
- 比如
B
没收到A
的ACK
,那么B
就无法释放连接。 - 又比如
A
没收到B
的FIN
,那么A
就无法释放连接,也无法发送ACK
,导致B
也无法释放连接。
解决的方法是使用定时器,如果在一定时间内,没有对FIN
的响应ACK
,那么发出FIN
的一方可以直接释放连接。另一方也会注意到似乎对面已经没人在监听连接了,所以也会超时。
各层介绍完毕,可以开始分析HTTP请求过程了。
先简单回顾一下例子:我们要访问的是www.example.com。大致的过程为:
- 域名解析:获得www.example.com的IP地址。
- 与该IP地址的80端口建立TCP连接。
- 发送HTTP请求。
- 得到服务端的响应。
- 断开TCP连接。
域名解析
- 首先,浏览器会搜索自身的DNS缓存(缓存时间比较短,且条数较少),如果搜到了www.example.com对应的条目,且没有过期,那么解析成功,该过程结束。
- 浏览器表示没搜到,它接着会搜索操作系统的DNS缓存。同样,如果搜到且没有过期,解析成功。
- 浏览器表示操作系统里也没有啊,它会去hosts文件里搜索,如果有,则解析成功。
- 浏览器表示还是没找到……现在麻烦了,要向DNS服务器发请求了。DNS服务器地址就是我们机器上配置的“DNS服务器”(比如8.8.8.8什么的)。这是通过UDP请求来完成的。
- 你的电脑问DNS服务器
A
:“喂,www.example.com的IP地址是多少?”如果A
知道,那么就返回结果,过程结束。 - 如果
A
不知道,那么,它会帮你问更高一级的DNS服务器B
。同样,如果B
知道,它就将结果返回给A
,然后A
再把结果告诉给你的电脑,过程结束。 - 如果
B
也不知道,它会和A
说:“啊,我不知道,但是我知道.com
是C
负责的,它应该知道,我把它的IP地址告诉你,你去问它好了”。 - 这下
A
又会去问C
。如此这般,一直问到某个服务器返回了结果为止。 - 总的来说,这个过程就是:你的电脑拜托
A
找到www.example.com的IP地址,而A
则会尽心尽力的帮你问,直到找到答案为止。
- 你的电脑问DNS服务器
- 最后我们终于得到了www.example.com的ip地址93.184.216.34
TCP连接
得到域名对应的IP地址后,浏览器会随机开启一个端口(1024<port<65535)向服务器的80端口发起TCP连接请求,图中为57320端口。如前所述,客户端首先发送[SYN] Seq=0
,服务端返回[SYN, ACK] Seq=0 Ack=1
,客户端又发送[ACK] Seq=1 Ack=1
。三次握手完成。
整个过程抓包如上图。
HTTP请求与响应
建立了TCP连接之后,浏览器发起HTTP Request,使用的是GET
方法。服务端返回一个ACK
后开始真正的数据传输。服务端应用层将一个HTML文档传给传输层,由于这个HTML文档有点长,传输层将其分为两段22751
与22752
,传输层收到了两个段之后再一起传给应用层,因此对于应用层来说相当于只收到了一个HTTP响应。客户端收到响应后发送了一个ACK
。
TCP关闭连接
客户端发送[FIN, ACK] Seq=144 Ack=1615
说自己要断开连接了,服务器响应这个FIN
,发回一个[FIN ACK] Seq=1615 Ack=145
。客户端收到后最后发送一次ACK
,[ACK] Seq=145 Ack=1616
。
一些问题
路由器和网关有什么区别和联系?
- 之前说过,在传统的TCP/IP术语中,除了主机以外的设备都是网关。也就是说,在当时,网关与路由器还没有区别。
- 在现代网络术语中,网关能在不同协议间移动数据,而路由器是在不同网络间移动数据。
- 现在,路由器一般都实现了网关的功能。
为什么以太网帧有最小长度要求?
这其实是一个历史遗留问题。如前所述,经典以太网的使用总线拓扑,多台机器接入同一条线缆,因此存在冲突。那么我们考虑这样一种情况:一个非常短的帧由A发往B,然而很不幸的是,就在帧要到达B时,B也发送了一个帧,并且很快B发现产生了冲突,然后发出了一个48位的突发噪声来警告别的机器。然而,在这个冲突警告到达A前,A早已完成了数据帧的传输。又由于以太网不采用确认机制,所以A认为传输成功,然而实际上是失败的。为了避免这种情况的发生,我们应该使数据帧足够长,使得在警告信号到达时,数据帧仍未发送完毕。结合一些以太网物理参数,可以算出这个最小长度为64字节。
以太网不使用确认机制,万一传输过程中发生了错误怎么办?
前面说到,如果确实发生了传输错误,高层需要负责处理,而这就取决于具体是什么协议。对于UDP,发送方不知道、也不需要知道传输错误的发生。对于TCP,则是通过传输层的超时机制来完成的。由于发送方的数据链路层计算校验和后检测到错误,将其丢弃,因此网络层收不到该分组,因此传输层收不到该分组所在的数据段,也就没法给发送方发送ACK。导致发送方计时器超时,进而重新发送该数据段。
不过这就导致了一个问题……假设传输层的数据段比较长(大于1500字节)被网络层分成了k组,那么其中任何1组出现丢包/校验和错误等情况,对端的传输层都将收不到该数据段。发送方需要重传整个数据段,这个代价是比较大的。因此,TCP会极力避免自己的数据段在网络层被分组。也就是说,TCP会先将自己的数据分为k个数据段,而这k个数据段由于没有超过1500字节,因此在传输层会被包装为k个数据包。这样一来,其中1组(同时也是1段)出现丢包/出错的情况,也只用重传这一组就行了。