0%

Network 探索协议栈和网卡

介绍了 TCP 协议的收发过程,即原始数据先由协议栈进行封装处理后拆成网络数据包,通过建立连接,以电信号的形式经由网卡、以太网,转达至目的地,并经过协议栈的解析,还原为原始数据后关闭连接的整个过程。

下图为第二章内容概览

TCP协议收发操作

创建Socket

创建套接字时,首先分配一个套接字所需的内存空间,然后向其中写入初始状态。接下来,需要将表示这个套接字的描述符告知应用程序。

协议栈内部结构

查看套接字的内容

消息收发操作

连接服务器

连接实际上是通信双方交换控制信息,在套接字中记录这些必要信息并准备数据收发的一连串操作。连接操作中所交换的控制信息是根据通信规则来确定的,只要根据规则执行连接操作,双方就可以得到必要的信息从而完成数据收发的准备。此外,当执行数据收发操作时,我们还需要一块用来临时存放要收发的数据的内存空间,这块内存空间称为缓冲区,它也是在连接操作的过程中分配的。

控制信息类型

通信操作中使用的控制信息分为两类:TCP头部中记录的信息、套接字(协议栈中的内存空间)中记录的信息

  1. 客户端和服务器相互联络时交换的控制信息:TCP头部信息。主要负责记录和交换控制信息。

  1. 控制协议栈操作的信息以及收发数据操作的执行状态等信息:它们会保存到 Socket,协议栈会
    根据这些信息来执行每一步的操作。

连接操作的实际过程

应用程序调用 Socket 库的 connect 开始,调用信息会传递给协议栈中的 TCP 模块。首先 TCP 会先创建一个包含表示开始数据收发操作的控制信息的头部,将头部中的控制位的 SYN 比特设置为 1,设置适当的序号和窗口大小,创建好后将信息传递给 IP 模块,委托它进行发送。IP 模玦会在网络包前面添加 IP 头部和以太网的 MAC 头部后发送网络包。

网络包会通过网络到达服务器,然后服务器上的 IP 模块会将接收到的数据传递给 TCP 模块。服务器的 TCP 模块根据 TCP 头部中的信息找到端口号对应的 Socket,也就是在等待连接的 Socket 中找到与 TCP 头部中记录的端口号相同的。找到后将状态改为正在连接并返回响应。该响应需要在 TCP 头部中设置发送方和接收方端口号、SYN 比特、ACK 控制位设为 1,ACK 表示已经接收到相应的网络包。接下来,服务器 TCP 模块会将 TCP 头部传递给 IP 模块,并委托 IP 模块向客户端返回响应。

然后,网络包就会返回到客户端,通过 IP 模块到达 TCP 模块,并通过 TCP 头部的信息确认连接服务器的操作是否成功。如果 SYN 为 1 则表示连接成功,这时会向套接字中写入服务器的 IP 地址、端口号等信息,同时还会将状态改为连接完毕。

随后,客户端需要将 ACK 比特设置为 1 并发回服务器,告诉服务器刚才的响应包已经收到。当这个服务器收到这个返回包之后,连接操作才算全部完成。

收发数据

将HTTP请求消息交给协议栈

客户端获取到应用层传递来的数据后,首先需要判断数据长度,只有累计一定量的数据后才会一次性发送出去。

第一个判断要素是每个网络包能容纳的数据长度,涉及到 MTU 和 MSS 两个参数。

MTU:一个网络包的最大长度,以太网中一般为1500字节。
MSS:除去头部之后,一个网络包所能容纳的TCP数据的最大长度。

另一个判断要素是时间,当应用程序发送数据的频率不高的时候,如果每次都等到长度接近 MSS 时再发送,可能会因为等待时间太长而造成发送延迟,这种情况下,即便缓冲区中的数据长度没有达到 MSS,也应该果
断发送出去。为此,协议栈的内部有一个计时器,当经过一定时间之后,就会把网络包发送出去。

对较大的数据进行拆分

当提交的数据超过网络包的容纳量,需要对数据进行拆分。

使用ACK号确认网络包已收到

返回 ACK 号时,除了要设置 ACK 号的值以外,还需要将控制位中的 ACK 比特设为 1,这代表 ACK 号字段有效,接收方也就可以知道这个网络包是用来告知 ACK 号的。

需要注意的是,在实际的通信中,序号并不是从 1 开始的,而是需要用随机数计算出一个初始值,这是因为
如果序号都从 1 开始,通信过程就会非常容易预测,有人会利用这一点来发动攻击。但是如果初始值是随机的,那么对方就搞不清楚序号到底是从多少开始计算的,因此需要在开始收发数据之前将初始值告知通信对象。

大家应该还记得在我们刚才讲过的连接过程中,有一个将 SYN 控制位设为 1 并发送给服务器的操作,就是在这一步将序号的初始值告知对方的。实际上,在将 SYN 设为 1 的同时,还需要同时设置序号字段的值,而这里的值就代表序号的初始值。

通过 ACK 来进行纠错确认这一机制非常强大,可以对接收方没有收到的包进行重传。因此,网卡、集线器、路由器都不必要设置错误补偿机制,一旦检测到错误就直接丢弃相应的包。

不过,如果发生网络中断、服务器宕机等问题,那么无论 TCP 怎样重传都不管用。这种情况下,无论如何尝试都是徒劳,因此 TCP 会在尝试几次重传无效之后强制结束通信,并向应用程序报错。

根据网络包平均往返时间调整ACK号等待时间

当网络传输繁忙时就会发生拥塞,ACK 号的返回会变慢,这时我们就必须将等待时间设置得稍微长一点,否则可能会发生已经重传了包之后,前面的 ACK 号才姗姗来迟的情况。这样的重传是多余的,看上去只是多
发一个包而已,但它造成的后果却没那么简单。因为 ACK 号的返回变慢大多是由于网络拥塞引起的,因此如果此时再出现很多多余的重传,对于本来就很拥塞的网络来说无疑是雪上加霜。那么等待时间是不是越长越好
呢?也不是。如果等待时间过长,那么包的重传就会出现很大的延迟,也会导致网络速度变慢。

看来等待时间需要设为一个合适的值,不能太长也不能太短,但这谈何容易。根据服务器物理距离的远近,ACK 号的返回时间也会产生很大的波动,而且我们还必须考虑到拥塞带来的影响。正因为波动如此之大,所以将等待时间设置为一个固定值并不是一个好办法。因此,TCP 采用了动态调整等待时间的方法,这个等待时间是根据 ACK 号返回所需的时间来判断的。具体来说,TCP 会在发送数据的过程中持续测量 ACK 号的返回时间,如果 ACK 号返回变慢,则相应延长等待时间;相对地,如果 ACK 号马上就能返回,则相应缩短等待时间。

使用窗口有效管理ACK号

TCP 采用滑动窗口方式来管理数据发送和 ACK 号的操作。

虽然这样做能够减少等待 ACK 号时的时间浪费,但有一些问题需要注意。在一来一回方式中,接收方完成接收操作后返回 ACK 号,然后发送方收到 ACK 号之后才继续发送下一个包,因此不会出现发送的包太多接收方处理不过来的情况。但如果不等返回 ACK 号就连续发送包,就有可能会出现发送包的频率超过接收方处理能力的情况。

超过处理能力的结果就是缓冲区溢出,缓冲区溢出之后,后面的数据就进不来了,因此接收方就收不到后面的包了,这就和中途出错的结果是一样的。我们可以通过下面的方法来避免这种情况的发生。首先,接收方需要告诉发送方自己最多能接收多少数据,然后发送方根据这个值对数据发送操作进行控制,这就是滑动窗口方式的基本思路。

ACK与窗口的合并

更新窗口大小的时机:接收方从缓冲区中取出数据传递给应用程序的时候。
ACK 返回的时机:接收方收到数据后。

首先,发送方的数据到达接收方,在接收操作完成之后就需要向发送方返回 ACK 号,而再经过一段时间,当数据传递给应用程序之后才需要更新窗口大小。但如果根据这样的设计来实现,每收到一个包,就需要向发送方分别发送 ACK 号和窗口更新这两个单独的包。这样一来,接收方发给发送方的包就太多了,导致网络效率下降。

因此,接收方在发送 ACK 号和窗口更新时,并不会马上把包发送出去,而是会等待一段时间,在这个过程中很有可能会出现其他的通知操作,这样就可以把两种通知合并在一个包里面发送了。举个例子,在等待发送
ACK 号的时候正好需要更新窗口,这时就可以把 ACK 号和窗口更新放在一个包里发送,从而减少包的数量。当需要连续发送多个 ACK 号时,也可以减少包的数量,这是因为 ACK 号表示的是已收到的数据量,也就是
说,它是告诉发送方目前已接收的数据的最后位置在哪里,因此当需要连续发送 ACK 号时,只要发送最后一个 ACK 号就可以了,中间的可以全部省略。当需要连续发送多个窗口更新时也可以减少包的数量,因为连续发生窗口更新说明应用程序连续请求了数据,接收缓冲区的剩余空间连续增加。这种情况和 ACK 号一样,可以省略中间过程,只要发送最终的结果就可以了。

接收HTTP响应消息

首先,浏览器在委托协议栈发送请求消息之后,会调用 read 程序来获取响应消息。然后,控制流程会通过 read 转移到协议栈,然后协议栈会执行接下来的操作。和发送数据一样,接收数据也需要将数据暂存到接收缓冲区中。首先,协议栈尝试从接收缓冲区中取出数据并传递给应用程序,但这个时候请求消息刚刚发送出去,响应消息可能还没返回。响应消息的返回还需要等待一段时间,因此这时接收缓冲区中并没有数据,那么接收数据的操作也就无法继续。这时,协议栈会将应用程序的委托,也就是从接收缓冲区中取出数据并传递给应用程序的工作暂时挂起,等服务器返回的响应消息到达之后再继续执行接收操作。

从服务器断开并删除套接字

数据发送完毕后断开连接

具体来说就是将控制位中的 FIN 比特设为 1 并相互通知。

删除套接字

和服务器的通信结束之后,用来通信的套接字也就不会再使用了,这时套接字并不会立即被删除,而是
会等待一段时间之后再被删除。

等待这段时间是为了防止误操作,引发误操作的原因有很多,这里无法全部列举,下面来举一个最容易理解的例子。假设客户端先发起断开,则断开的操作顺序如下:

(1)客户端发送 FIN
(2)服务器返回 ACK 号
(3)服务器发送 FIN
(4)客户端返回 ACK 号

如果最后客户端返回的 ACK 号丢失了,结果会如何呢?这时,服务器没有接收到 ACK 号,可能会重发一次 FIN。如果这时客户端的套接字已经删除了,会发生什么事呢?套接字被删除,那么套接字中保存的控制信息也就跟着消失了,套接字对应的端口号就会被释放岀来。这时,如果别的应用程序要创建套接字,新套接字碰巧又被分配了同一个端口号,而服务器重发的 FIN 正好到达,会怎么样呢?本来这个 FIN 是要发给刚刚删除的那个套接字的,但新套接字具有相同的端口号,于是这个 FIN 就会错误地跑到新套接字里面,新套接字就开始执行断开操作了。之所以不马上删除套接字,就是为了防止这样的误操作。

至于具体等待多长时间,这和包重传的操作方式有关。网络包丢失之后会进行重传,这个操作通常要持续几分钟。如果重传了几分钟之后依然无效,则停止重传。在这段时间内,网络中可能存在重传的包,也就有可能发
生前面讲到的这种误操作,因此需要等待到重传完全结束。协议中对于这个等待时间没有明确的规定,一般来说会等待几分钟之后再删除套接字。

数据收发操作总结

IP与以太网的包收发操作

包收发操作概览

包收发操作的起点是 TCP 模块委托 IP 模块发送包的操作。这个委托的过程就是 TCP 模块在数据块的前面加上 TCP 头部(包含 IP 地址等),然后整个传递给 IP 模块,这部分就是网络包的内容。收到委托后,IP 模块会将包的内容当作一整块数据,在前面加上包含控制信息的头部:IP 头部和 MAC 头部这两种头部。IP 头部中包含 IP 协议规定的、根据 IP 地址将包发往目的地所需的控制信息;MAC 头部包含通过以太网的局域网将包传输至最近的路由器所需的控制信息。 

当使用除以太网之外的其他网络进行传输时,MAC 头部也会被替换为适合所选通信规格的其他头部。

接下来,封装好的包会被交给网络硬件,例如以太网、无线局域网等。网络硬件可能是插在计算机主板上的板卡,也可能是笔记本电脑上的 PCMCIA 卡,或者是计算机主板上集成的芯片,不
同形态的硬件名字也不一样,本书将它们统称为网卡。网卡会将这些数字信息转换为电信号或光信号,并通过网线(或光纤)发送出去,然后这些信号就会到达集线器、路由器等转发设备,再由转发设备一步一步地送达接收方。

包送达对方之后,对方会作出响应。返回的包也会通过转发设备发送回来,然后我们需要接收这个包。接收的过程和发送的过程是相反的,信息先以电信号的形式从网线传输进来,然后由网卡将其转换为数字信息并传递给 IP 模块。接下来,IP 模块会将 MAC 头部和 IP 头部后面的内容,也就是 TCP 头部加上数据块,传递给 TCP 模块。

凡是局域网所使用的头部都叫 MAC 头部,但其内容根据局域网的类型有所不同。此外,对于除局域网之外的其他通信技术,还有不同名称的各种头部,但它们只是名字不叫 MAC 头部而已,承担的作用和 MAC 头部是相同的。

生成包含接收方IP地址的IP头部

IP 模块接受 TCP 模块的委托负责包的收发工作,它会生成 IP 头部并附加在 TCP 头部前面。

下图是 IP 头部格式

IP 是分配给网卡的,每一个网卡都有一个 IP 地址

如果一台计算机有多个网卡,该如何判断呢?和路由器使用 IP 表判断下一个路由器位置的操作是一样的。因为协议栈的 IP 模块与路由器中负责包收发的部分都是根据 IP 协议规则来进行包收发操作的,所以它们也都用相同的方法来判断把包发送给谁。而其使用的称之为路由表,如下图。

Gateway(网关)路由器,IP模块根据它来判断下一步把包发给谁
Interface(接口)表示网卡等网络接口

如果 Gateway 和 Interface 列的 IP 地址相同,就表示不需要路由器进行转发,可以直接将包发给接收方的 IP 地址。并且表的第一行,目标地址和子网掩码都是 0.0.0.0,这表示默认网关,如果其他所有条目都无法匹配,就会自动匹配这一行。

生成以太网用的MAC头部

IP 模块在生成 IP 头部之后,会在它前面再加上MAC 头部。MAC 头部是以太网使用的头部,它包含了接收方和发送方的 MAC 地址等信息。

这时我们还不知道目标的 MAC 地址是多少,需要根据 IP 地址查询 MAC 地址。我们这时就需要用到 ARP 协议(Address Resolution Protocol,地址解析协议)了。

它的原理很简单,它向连接在同一以太网中的所有设备进行广播,询问发送包中记录的 Gateway 的 MAC 地址,待有节点响应后记录放入 ARP 缓存中。

实际上,只有在操作系统启动过程中对网卡进行初始化的时候才会读取 MAC 地址,读取出来之后会存放在内存中,每次执行收发操作时实际上使用的是内存中的值。此外,读取 MAC 地址的操作是由网卡驱动程序来完成的,因此网卡驱动程序也可以不从网卡 ROM 中读取地址,而是将配置文件中设定的 MAC 地址拿出来放到内存中并用于设定 MAC 头部,或者也可以通过命令输入 MAC 地址。

下图为 ARP 缓存中记录的内容

下图为 MAC 地址书写的格式

与 IP 表相同,路由表也一样会遇到映射关系发生变化的情况,因此缓存内容也会一定时间内过期。

将 MAC 头部加在 IP 头部的前面,整个包就完成了。到这里为止,整个打包的工作是由 IP 模块负责的。有人认为,MAC 头部是以太网需要的内容,并不属于 IP 的职责范围,但从现实来看,让 IP 负责整个打包工作是有利的。如果在交给网卡之前,IP 模块能够完成整个打包工作,那么网卡只要将打好
的包发送出去就可以了。对于除 IP 以外的其他类型的包也是一样,如果在交给网卡之前完成打包,那么对于网卡来说,发送的操作和发送 IP 包是完全相同的。这样一来,同一块网卡就可以支持各种类型的包。至于接收操作,如果接收的包可以原封不动直接交给 IP 模块来处理,网卡就只要负责接收就可以了。这样一来,一块网卡也就能支持各种类型的包了。与其机械地设计模块和设备之间的分工,导致网卡只能支持 IP 包,不如将分工设计得现实一些,让网卡能够灵活支持各种类型的包。

以太网结构

以太网是一种为多台计算机能够彼此自由和廉价地相互通信而设计的通信技术。

(a)是以太网的原型,通过收发器网线将所有设备的信号连接起来。因此,当一台计算机发送信号时,
信号就会通过网线流过整个网络,最终到达所有的设备。随后根据信号开头的接收者信息,判断自己是否需要接受/丢弃。

后演进为(b),这个结构是将主干网线替换成了一个中继式集线器,将收发器网线替换成了双绞线。不过,虽然网络的结构有所变化,但信号会发送给所有设备这一基本性质并没有改变。

再演进为现在的(c),尽管结构与(b)类似,但不同的是:现在信号只会流到根据 MAC 地址指定的设备,而不会到达其他设备了。

将IP包转换成电或光信号发送出去

通过网卡获取自身MAC地址

网卡的 ROM 中保存着全世界唯一的 MAC 地址,这是在生产网卡时写入的。并且网卡中保存的 MAC 地址会由网卡驱动程序读取并分配给 MAC 模块。

MAC模块再添加三个控制数据

MAC 模块会将包从缓冲区中取出,并在开头加上报头和起始帧分界符,在末尾加上用于检测错误的帧校验序列。

报头

报头是一串像 10101010… 这样 1 和 0 交替出现的比特序列,长度为 56 比特,它的作用是确定包的读取时机。当这些 1010 的比特序列被转换成电信号后,会形成下图所示波形。接收方在收到信号时,遇到这样的波形就可以判断读取数据的时机。

用电信号来表达数字信息时,我们需要让 0 和 1 两种比特分别对应特定的电压和电流,如下图(a)这样的电信号就可以表达数字信息。通过电信号来读取数据的过程就是将这种对应关系颠倒过来。也就是说,通过测量信号中的电压和电流变化,还原出 0 和 1 两种比特的值。然而,实际的信号并不像下图所示的那样有分隔每个比特的辅助线,因此在测量电压和电流时必须先判断出每个比特的界限在哪里。但是,像右边这种 1 和 0 连续出现的信号,由于电压和电流没有变化,我们就没办法判断出其中每个比特到底应该从哪里去切分。

要解决这个问题,最简单的方法就是在数据信号之外再发送一组用来区分比特间隔的时钟信号,如(b)。当时钟信号从下往上变化时,读取电压和电流的值,然后和 0 或 1 进行对应就可以了。但是这种方法存在问题。当距离较远,网线较长时,两条线路的长度会发生差异,数据信号和时钟信号的传输会产生时间差,时钟就会发生偏移。

要解决这个问题,可以采用将数据信号和时钟信号叠加在一起的方法。这样的信号如(c),发送方将这样的信号发给接收方。由于时钟信号是按固定频率进行变化的,只要能够找到这个变化的周期,就可以从接收到的信号(c)中提取出时钟信号(b),进而通过接收信号(c)和时钟信号(b)计算出数据信号(a),这和发送方将数据信号和时钟信号进行叠加的过程正好相反。然后,只要根据时钟信号(b)的变化周期,我们就可以从数据信号(a)中读取相应的电压和电流值,并将其还原为 0 或 1 的比特了。

这里的重点在于如何判断时钟信号的变化周期。时钟信号是以10 Mbit/s 或者100 Mbit/s 这种固定频率进行变化的,就像我们乘坐自动扶梯一样,只要对信号进行一段时间的观察,就可以找到其变化的周期。因此,我们
不能一开始就发送包的数据,而是要在前面加上一段用来测量时钟信号的特殊信号,这就是报头的作用。

如果在包信号结束之后,继续传输时钟信号,就可以保持时钟同步的状态,下一个包就无需重新进行同步。有些通信方式采用了这样的设计,但以太网的包结束之后时钟信号也跟着结束了,没有通过这种方式来保持时钟同步,因此需要在每个包的前面加上报头,用来进行时钟同步。

FCS

末尾的 FCS(帧校验序列)用来检查包传输过程中因噪声导致的波形紊乱、数据错误,它是一串 32 比特的序列,是通过一个公式对包中从头到尾的所有内容进行计算而得出来的。具体的计算公式在此省略,它和磁盘等
设备中使用的 CRC(Cyclic Redundancy Check,循环冗余校验) 错误校验码是同一种东西,当原始数据中某一个比特发生变化时,计算出来的结果就会发生变化。在包传输过程中,如果受到噪声的干扰而导致其中的数据发生了变化,那么接收方计算出的 FCS 和发送方计算出的 FCS 就会不同,这样我们就可以判断出数据有没有错误。

向集线器发送网络包

发送信号的操作分为两种,一种是使用集线器的半双工模式,另一种是使用交换机的全双工模式。

在半双工模式中,为了避免信号碰撞,首先要判断网线中是否存在其他设备发送的信号。如果有,则需要等待该信号传输完毕,因为如果在有信号时再发送一组信号,两组信号就会发生碰撞。当之前的信号传输完毕,
或者本来就没有信号在传输的情况下,我们就可以开始发送信号了。首先,MAC 模块从报头开始将数字信息按每个比特转换成电信号,然后由 PHY,或者叫 MAU 的信号收发模块发送出去。在这里,将数字信息转换为电信号的速率就是网络的传输速率,例如每秒将10 Mbit 的数字信息转换为电信号发送出去,则速率就是10 Mbit/s。

接下来,PHY(MAU)模块会将信号转换为可在网线上传输的格式,并通过网线发送出去。以太网规格中对不同的网线类型和速率以及其对应的信号格式进行了规定,但 MAC 模块并不关心这些区别,而是将可转换为任意格式的通用信号发送给 PHY(MAU)模块,然后 PHY(MAU)模块再将其转换为可在网线上传输的格式。大家可以认为 PHY(MAU)模块的功能就是对 MAC 模块产生的信号进行格式转换。当然,以太网还有很多不同的派生方式,网线传输的信号格式也有各种变化。此外,实际在网线中传输的信号很复杂,我们无法一一介绍,但是如果一点都不讲,大家可能对此难以形成一个概念,所以就举一个例子。

网卡的 MAC 模块生成通用信号,然后由 PHY(MAU)模块转换成可在网线中传输的格式,并通过网线发送出去。

PHY(MAU)的职责并不是仅仅是将 MAC 模块传递过来的信号通过网线发送出去,它还需要监控接收线路中有没有信号进来。在开始发送信号之前,需要先确认没有其他信号进来,这时才能开始发送。如果在信号开始发送到结束发送的这段时间内一直没有其他信号进来,发送操作就成功完成了。以太网不会确认发送的信号对方有没有收到。根据以太网的规格,两台设备之间的网线不能超过100 米,在这个距离内极少会发生错误,万一发生错误,协议栈的 TCP 也会负责搞定,因此在发送信号时没有必要检查错误。

在发送信号的过程中,接收线路不应该有信号进来,但情况并不总是尽如人意,有很小的可能性出现多台设备同时进行发送操作的情况。如果有其他设备同时发送信号,这些信号就会通过接收线路传进来。

在使用集线器的半双工模式中,一旦发生这种情况,两组信号就会相互叠加,无法彼此区分出来,这就是所谓的信号碰撞。这种情况下,继续发送信号是没有意义的,因此发送操作会终止。为了通知其他设备当前线路已发生碰撞,还会发送一段时间的阻塞信号C,然后所有的发送操作会全部停止。

等待一段时间之后,网络中的设备会尝试重新发送信号。但如果所有设备的等待时间都相同,那肯定还会发生碰撞,因此必须让等待的时间相互错开。具体来说,等待时间是根据 MAC 地址生成一个随机数计算出来的。

当网络拥塞时,发生碰撞的可能性就会提高,重试发送的时候可能又会和另外一台设备的发送操作冲突,这时会将等待时间延长一倍,然后再次重试。以此类推,每次发生碰撞就将等待时间延长一倍,最多重试 10 次,如果还是不行就报告通信错误。

另一种全双工模式中,发送和接收可以同时进行,不会发生碰撞。因此,全双工模式中不需要像半双工模式这样考虑这么多复杂的问题,即便接收线路中有信号进来,也可以直接发送信号。

接受返回包

网卡将包转换为电信号并发送出去的过程到这里就结束了,既然讲到了以太网的工作方式,那我们不妨继续看看接收网络包时的操作过程。

在使用集线器的半双工模式以太网中,一台设备发送的信号会到达连接在集线器上的所有设备。这意味着无论是不是发给自己的信号都会通过接收线路传进来,因此接收操作的第一步就是不管三七二十一把这些信号全都收进来再说。

信号的开头是报头,通过报头的波形同步时钟,然后遇到起始帧分界符时开始将后面的信号转换成数字信息。这个操作和发送时是相反的,即 PHY(MAU)模块先开始工作, 然后再轮到 MAC 模块。首先,PHY(MAU)模块会将信号转换成通用格式并发送给 MAC 模块,MAC 模块再从头开始将信号转换为数字信息,并存放到缓冲区中。当到达信号的末尾时,还需要检查 FCS。具体来说,就是将从包开头到结尾的所有比特套用到公式中计算出 FCS,然后和包末尾的 FCS 进行对比,正常情况下两者应该是一致的,如果中途受到噪声干扰而导致波形发生紊乱,则两者的值会产生差异,这时这个包就会被当作错误包而被丢弃。

如果 FCS 校验没有问题,接下来就要看一下 MAC 头部中接收方 MAC 地址与网卡在初始化时分配给自己的 MAC 地址是否一致,以判断这个包是不是发给自己的。我们没必要去接收发给别人的包,因此如果不是自己的包就直接丢弃,如果接收方 MAC 地址和自己 MAC 地址一致,则将包放入缓冲区中。到这里,MAC 模块的工作就完成了,接下来网卡会通知计算机收到了一个包。

通知计算机的操作会使用一个叫作中断的机制。在网卡执行接收包的操作的过程中,计算机并不是一直监控着网卡的活动,而是去继续执行其他的任务。因此,如果网卡不通知计算机,计算机是不知道包已经收到了这件事的。网卡驱动也是在计算机中运行的一个程序,因此它也不知道包到达的状态。在这种情况下,我们需要一种机制能够打断计算机正在执行的任务,让计算机注意到网卡中发生的事情,这种机制就是中断。

具体来说,中断的工作过程是这样的。首先,网卡向扩展总线中的中断信号线发送信号,该信号线通过计算机中的中断控制器连接到CPU。当产生中断信号时,CPU 会暂时挂起正在处理的任务,切换到操作系统中的
中断处理程序。然后,中断处理程序会调用网卡驱动,控制网卡执行相应的接收操作。

中断是有编号的,网卡在安装的时候就在硬件中设置了中断号,在中断处理程序中则将硬件的中断号和相应的驱动程序绑定。例如,假设网卡的中断号为 11,则在中断处理程序中将中断号 11 和相应的网卡驱动绑定起来,当网卡发起中断时,就会自动调用网卡驱动了。现在的硬件设备都遵循即插即用规范自动设置中断号,我们没必要去关心中断号了,在以前需要手动设置中断号的年代,经常发生因为设置了错误的中断号而导致网
卡无法正常工作的问题。

网卡驱动被中断处理程序调用后,会从网卡的缓冲区中取出收到的包,并通过 MAC 头部中的以太类型字段判断协议的类型。现在我们在大多数情况下都是使用 TCP/IP 协议,但除了 TCP/IP 之外还有很多其他类型的协议,例如 NetWare 中使用的 IPX/SPX,以及 Mac 电脑中使用的 AppleTalk 等协议。这些协议都被分配了不同的以太类型,如 0080(十六进制)代表 IP 协议,网卡驱动就会把这样的包交给 TCP/IP 协议栈;如果是 809B 则表示 AppleTalk 协议,就把包交给 AppleTalk 协议栈,以此类推。

按照探索之旅的思路,大家可能会认为向 Web 服务器发送包之后,后面收到的一定是 Web 服务器返回的包,其实并非如此。计算机中同时运行了很多程序,也会同时进行很多通信操作,因此收到的包也有可能是其他
应用程序的。不过,即便如此也没问题,网卡不会关心包里的内容,只要按照以太类型将包交给对应的协议栈就可以了。接下来,协议栈会判断这个包应该交给哪个应用程序,并进行相应的处理。

将服务器的响应包从IP传递给TCP

下面我们假设 Web 服务器返回了一个网络包,那么协议栈会进行哪些处理呢?服务器返回的包的以太类型应该是0800,因此网卡驱动会将其交给 TCP/IP 协议栈来进行处理。接下来就轮到 IP 模块先开始工作了,第一步是检查 IP 头部,确认格式是否正确。如果格式没有问题,下一步就是查看接收方 IP 地址。如果接收网络包的设备是一台 Windows 客户端计算机,那么服务器返回的包的接收方 IP 地址应该与客户端网卡的地址一致,
检查确认之后我们就可以接收这个包了。

如果接收方 IP 地址不是自己的地址,那一定是发生了什么错误。客户端计算机不负责对包进行转发,因此不应该收到不是发给自己的包。当发生这样的错误时,IP 模块会通过 ICMP 消息将错误告知发送方。ICMP 规定了各种类型的消息,如下表。当我们遇到这个错误时,IP 模块会通过下表中的 Destination unreachable 消息通知对方。从这张表的内容中我们可以看到在包的接收和转发过程中能够遇到的各种错误。

如果接收方 IP 地址正确,则这个包会被接收下来,这时还需要完成另一项工作。IP 协议有一个叫作分片的功能。简单来说,网线和局域网中只能传输小包,因此需要将大的包切分成多个小包。如果接收到的包是经过分片的,那么 IP 模块会将它们还原成原始的包。分片的包会在 IP 头部的标志字段中进行标记,当收到分片的包时,IP 模块会将其暂存在内部的内存空间中,然后等待 IP 头部中具有相同 ID 的包全部到达,这是因为同一个包的所有分片都具有相同的 ID。此外,IP 头部还有一个分片偏移量(fragment offset)字段,它表示当前分片在整个包中所处的位置。根据这些信息,在所有分片全部收到之后,就可以将它们还原成原始的包,这个操作叫作分片重组。

到这里,IP 模块的工作就结束了,接下来包会被交给 TCP 模块。TCP 模块会根据 IP 头部中的接收方和发送方 IP 地址,以及 TCP 头部中的接收方和发送方端口号来查找对应的套接字。找到对应的套接字之后,就可以
根据套接字中记录的通信状态,执行相应的操作了。例如,如果包的内容是应用程序数据,则返回确认接收的包,并将数据放入缓冲区,等待应用程序来读取;如果是建立或断开连接的控制包,则返回相应的响应控制包,并告知应用程序建立和断开连接的操作状态。

严格来说,TCP 模块和 IP 模块有各自的责任范围,TCP 头部属于 TCP 的责任范围,而 IP 头部属于 IP 模块的责任范围。根据这样的逻辑,当包交给 TCP 模块之后,TCP 模块需要查询 IP 头部中的接收方和发送方 IP 地址来查找相应的套接字,这个过程就显得有点奇怪。因为 IP 头部是 IP 模块负责的,TCP 模块去查询它等于是越权了。如果要避免越权,应该对两者进行明确的划分,IP 模块只向 TCP 模块传递 TCP 头部以及它后面的数据,而对于 IP 头部中的重要信息,即接收方和发送方的 IP 地址,则由 IP 模块以附加参数的形式告知 TCP 模块。然而,如果根据这种严格的划分来开发程序的话,IP 模块和 TCP 模块之间的交互过程必然会产生成本,而且 IP 模块和 TCP 模块进行类似交互的场景其实非常多,总体的交互成本就会很高,程序的运行效率就会下降。因此,就像之前提过的一样,不妨将责任范围划分得宽松一些,将 TCP 和 IP 作为一个整体来看待,这样可以带来更大的灵活性。

UDP协议收发操作

使用场景一:数据短

不过,在某种情况下,即便没有 TCP 这样复杂的机制,我们也能够高效地重发数据,这种情况就是数据很短,用一个包就能装得下。如果只有一个包,就不用考虑哪个包未送达了,因为全部重发也只不过是重发一个
包而已,这种情况下我们就不需要 TCP 这样复杂的机制了。而且,如果不使用 TCP,也不需要发送那些用来建立和断开连接的控制包了。此外,我们发送了数据,对方一般都会给出回复,只要将回复的数据当作接收确认就行了,也不需要专门的接收确认包了。

这种情况就适合使用 UDP。像 DNS 查询等交换控制信息的操作基本上都可以在一个包的大小范围内解决,这种场景中就可以用 UDP 来代替 TCP。UDP 没有 TCP 的接收确认、窗口等机制,因此在收发数据之前也不需要交换控制信息,也就是说不需要建立和断开连接的步骤,只要在从应用程序获取的数据前面加上 UDP 头部,然后交给 IP 进行发送就可以了。接收也很简单,只要根据 IP 头部中的接收方和发送方 IP 地址,以及 UDP 头部中的接收方和发送方端口号,找到相应的套接字并将数据交给相应的应用程序就可以了。除此之外,UDP 协议没有其他功能了,遇到错误或者丢包也一概不管。因为 UDP 只负责单纯地发送包而已,并不像
TCP 一样会对包的送达状态进行监控,所以协议栈也不知道有没有发生错误。但这样并不会引发什么问题,因此出错时就收不到来自对方的回复,应用程序会注意到这个问题,并重新发送一遍数据。这样的操作本身并不复杂,也并不会增加应用程序的负担。

适用场景二:音频和视频数据

音频和视频数据必须在规定的时间内送达,一旦送达晚了,就会错过播放时机,导致声音和图像卡顿。如果像 TCP 一样通过接收确认响应来检查错误并重发,重发的过程需要消耗一定的时间,因此重发的数据很可能已经错过了播放的时机。一旦错过播放时机,重发数据也是没有用的,因为声音和图像已经卡顿了,这是无法挽回的。当然,我们可以用高速线路让重发的数据能够在规定的时间内送达,但这样一来可能要增加几倍的带宽才行。

此外,音频和视频数据中缺少了某些包并不会产生严重的问题,只是会产生一些失真或者卡顿而已,一般都是可以接受的。在这些无需重发数据,或者是重发了也没什么意义的情况下,使用 UDP 发送数据的效率会更高。

UDP 经常会被防火墙阻止,因此当需要穿越防火墙传输音频和视频数据时,尽管需要消耗额外的带宽,但有时候也只能使用 TCP。