Lever's Castle

Let's code a TCP/IP stack, 4 - TCP Data Flow & Socket API

January 26, 2019

阅读原文:http://www.saminiir.com/lets-code-tcp-ip-stack-4-tcp-data-flow-socket-api/

这篇文章中,我们将研究 TCP 数据通信及其管理方式。

除此之外,我们还会介绍可用于网络通信的网络堆栈接口,我们的示例程序将使用 socket API 向网站发送简单的 HTTP 请求。

传输控制块(TCB)

我们先从定义数据流状态的变量开始讨论,这样更有助于对 TCP 数据管理的理解。

简而言之,TCP 必须追踪他发送出去的数据和接收到的数据,为了维护追踪的情况,TCP 协议会为每个连接创建一个传输控制块(Transaction Control Block)。

发送方的变量定义如下:

Send Sequence Variables

  SND.UNA - send unacknowledged
  SND.NXT - send next
  SND.WND - send window
  SND.UP  - send urgent pointer
  SND.WL1 - segment sequence number used for last window update
  SND.WL2 - segment acknowledgment number used for last window update
  ISS     - initial send sequence number

接收方记录状态的变量定义如下:

Receive Sequence Variables

  RCV.NXT - receive next
  RCV.WND - receive window
  RCV.UP  - receive urgent pointer
  IRS     - initial receive sequence number

用来辅助处理当前数据段的变量定义如下:

Current Segment Variables

  SEG.SEQ - segment sequence number
  SEG.ACK - segment acknowledgment number
  SEG.LEN - segment length
  SEG.WND - segment window
  SEG.UP  - segment urgent pointer
  SEG.PRC - segment precedence value

这些变量会一起用来控制 TCP 的数据流动状态。

TCP 数据通信

连接建立完成后,就直接开始进行数据流处理了。TCB 中有 3 个变量对状态跟踪至关重要:

  • SND.NXT - 发送者需要根据 SND.NXT 来追踪下一个数据段
  • RCV.NXT - 接受者会将下一个将要发来的数据段序号记录到 RCV.NXT 中
  • SND.UNA - 发送者会在 SND.UNA 中记录最老的未被确认的序列号

等到 TCP 处理完全部数据通信,并且再没有数据需要传输时,这 3 个变量的值会相等。

举个例子,比如 A 要发送数据给 B,都需要经历哪些过程呢?

  1. A 的 TCP 发出了一段数据,并将自己的 SND.NXT 中的记录向后增加,成为下一个数据块的序列号
  2. B 的 TCP 收到这段数据后,将 RCV.NXT 中的记录向后增加,并返回 ACK 信息给 A
  3. A 收到 B 发来的 ACK,然后更新 SND.UNA 中的值

变量值增加的量,等于数据段的长度。

这是 TCP 数据通信的基础。我们借助 tcpdump 这个工具来看看传输过程的实际情况。

[saminiir@localhost level-ip]$ sudo tcpdump -i any port 8000 -nt
IP 10.0.0.4.12000 > 10.0.0.5.8000: Flags [S], seq 1525252, win 29200, length 0
IP 10.0.0.5.8000 > 10.0.0.4.12000: Flags [S.], seq 825056904, ack 1525253, win 29200, options [mss 1460], length 0
IP 10.0.0.4.12000 > 10.0.0.5.8000: Flags [.], ack 1, win 29200, length 0

10.0.0.4:12000 (A) 和 10.0.0.5:8000 (B) 建立了 TCP 连接。

三次握手之后,连接建立并且 TCP socket 状态更新为 ESTABLISHED。A 的初始化序列号为 1525252,B 的为 825056904。

IP 10.0.0.4.12000 > 10.0.0.5.8000: Flags [P.], seq 1:18, ack 1, win 29200, length 17
IP 10.0.0.5.8000 > 10.0.0.4.12000: Flags [.], ack 18, win 29200, length 0

A 发送了一个 17 bytes 大小的数据段给 B,而 B 响应了一条 ACK 消息给 A。为了可读性,tcpdump 中使用相对序列号,因此 ack 18 实际上是 1525253 + 17。

B 内部也把 RCV.NXT 增加了 17 。

IP 10.0.0.4.12000 > 10.0.0.5.8000: Flags [.], ack 1, win 29200, length 0
IP 10.0.0.5.8000 > 10.0.0.4.12000: Flags [P.], seq 1:18, ack 18, win 29200, length 17
IP 10.0.0.4.12000 > 10.0.0.5.8000: Flags [.], ack 18, win 29200, length 0
IP 10.0.0.5.8000 > 10.0.0.4.12000: Flags [P.], seq 18:156, ack 18, win 29200, length 138
IP 10.0.0.4.12000 > 10.0.0.5.8000: Flags [.], ack 156, win 29200, length 0
IP 10.0.0.5.8000 > 10.0.0.4.12000: Flags [P.], seq 156:374, ack 18, win 29200, length 218
IP 10.0.0.4.12000 > 10.0.0.5.8000: Flags [.], ack 374, win 29200, length 0

发送数据和确认回执在不断发生着,从上面的数据中可以看到,长度为 0 的数据段仅仅发送了 ACK 信息,但是这个数据段的序号却基于上次接收到的段长度精确的递增着。

IP 10.0.0.5.8000 > 10.0.0.4.12000: Flags [F.], seq 374, ack 18, win 29200, length 0
IP 10.0.0.4.12000 > 10.0.0.5.8000: Flags [.], ack 375, win 29200, length 0

B 将携带 FIN 的数据段发送给 A,以告知通信结束。同时,A 也会把确认终止的消息发送回来。

为了断开连接,A 也要发送同样的信号给 B。

TCP 连接终止

关闭 TCP 连接同样是个复杂的操作,可以强制终止(RST)或者相互发送 FIN 信号。

基本流程如下:

  1. 想要主动关闭连接的一方发送携带 FIN 信号的数据段给接收方
  2. 被动关闭的一方响应 ACK 信号
  3. 被动关闭的一方开始连接关闭操作,包括把未发送完的数据发送完和变成一个主动关闭的发起方
  4. 双方都发送 FIN 信号到对方并且都收到对方的 ACK 回执后,连接关闭

很明显,关闭一个 TCP 连接需要 4 步。而创建一个 TCP 连接 3 步就够了。

补充一点,TCP 是一个双向协议,因此可以让其中一端宣布他没有数据要发送了,但保持连接状态,以获取传入数据。这成为 TCP 连接的半关闭状态。

分组交换网络的不可靠,给终端的连接带来了更多的复杂度 —— FIN 信号可能丢失或者故意没发送,这让连接处于一个很尴尬的状态。因此,在 Linux 中,增加了内核参数 tcpfintimeout 用来控制 TCP 在强行关闭前等待最终 FIN 信号的时间。这样做虽然违反了规范,但是对于预防拒绝服务攻击来说是有好处的。

终止连接时会发送 RST 信号,有很多原因可能会导致连接终止,常见的有以下这些:

  1. 请求连接不存在的端口
  2. 对方的 TCP 程序崩溃或者处于失去同步连接的状态
  3. 试图扰乱现有的连接(比如我们的“great wall”,就是利用这一点干扰和阻止连接。参考 TCP 重置攻击

因此,发生 RST 对 TCP 来讲,是件比较悲伤的事情~~

Socket API

为了能够充分利用网络协议栈,需要为应用程序提供一些接口。BSD socket API 是最著名的一个,它起源于 1983 年的 4.2BSD UNIX 版本。 Linux 中的 Socket API 与 BSD Socket API 兼容。

通过调用 socket() 并传入 socket 和 协议的类型,来申请获得网络协议栈上的 socket。socket() 有 3 个参数:

  • domain,为创建的套接字指定协议集,例如:AFINET 表示 IPv4 网络协议,AFINET6 表示 IPv6 网络协议,AF_UNIX 表示本地套接字。
  • type,即 socket 类型,包括:SOCKSTREAM,SOCKDGRAM,SOCKSEQPACKET,SOCKRAW
  • protocol,指具体的传输协议,常见的有:IPPROTOTCP、IPPROTOSCTP、IPPROTOUDP、IPPROTODCCP

申请到 socket 后,就要开始连接远程端口了,connect() 就是用来启动三次握手建立连接的。

之后,我们就能调用 write() 和 read() 从 socket 中读取数据了。

网络协议栈会处理 TCP 流中的排队,重传,错误检查和重新排列。对于应用程序来讲,这是一个黑盒,应用程序可以完全信任 TCP 处理过后的数据流,一些意外情况也会通过 socket API 告知应用程序。

我们拿 curl 做为一个例子看看他是怎么实现的:

$ strace -esocket,connect,sendto,recvfrom,close curl -s example.com > /dev/null
...
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
connect(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("93.184.216.34")}, 16) = -1 EINPROGRESS (Operation now in progress)
sendto(3, "GET / HTTP/1.1\r\nHost: example.co"..., 75, MSG_NOSIGNAL, NULL, 0) = 75
recvfrom(3, "HTTP/1.1 200 OK\r\nCache-Control: "..., 16384, 0, NULL, NULL) = 1448
close(3)                                = 0
+++ exited with 0 +++

我们使用 strace 观察 socket API 的调用情况,strace 是一个跟踪系统调用和信号传递的工具。步骤如下:

  1. 申请 socket,指定协议为 IPv4/TCP
  2. 调用 connect 与目标主机进行三次握手建立连接,目标主机的地址和端口都写在参数里
  3. 连接建立完成后,sendto 用来将数据写入 socket 的一个方法,在这里我们发起了一个 HTTP GET 请求
  4. 最终 curl 程序通过调用 recvfrom 从 socket 中读取对方返回的数据。

精明的你一定发现,这里并没有出现 read() 和 write() 的调用。这是因为 socket API 并不包含这两个操作,这两个方法属于普通的 I/O 操作,也可以在 socket 这里使用,man 一下 socket:

In addition, the standard I/O operations like write(2), writev(2), sendfile(2), read(2), and readv(2) can be used to read and write data.

socket API 提供了多个方法可以写入或者读取数据,直接用 I/O 操作也是可以的,但是会让你的程序变的更加复杂。

最后

这一章我们基本实现了 TCP 的数据和通信管理,并提供了一个可以被应用程序使用的 socket API。

但是 TCP 数据通信不是一个简单的问题,数据包在传输过程中可能会遇到各种各样的问题。

因此 TCP 数据通信中还有一些别的更加复杂的逻辑,下一篇文章中,我们将研究 TCP 窗口管理和超时重传机制。


Lever

痕迹
没有过去,就没法认定现在的自己