Skip to content

yuuzao/tcpm

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tcpm

这是一个根据RFC 793文档实现的运行在用户态的TCP协议,使用的语言是Rust,目的只是为了学习TCPRust。 项目最初是fork自Jonhoo大佬的直播项目rust-tcpYoutube上有直播录像。但是原项目还很不完善,只通过了一个特殊场景的测试,也还存在一些错误,然后大佬就弃坑了:joy:。

我在这基础上根据RFC793并参考smoltcp重新写了TCP协议逻辑然后修正了一些其它东西。现在这个TCP实现在被动打开的情况下能正确工作,可以通过wrkHTTP连接压测(需要另外实现一个简单的HTTP server),也可以实现双向通信,也支持多个连接。不过在超低延迟(<1ms)情况下,由于架构设计的原因,wrk压测时超高频率的连接关闭和重连会引起错误(主要是还没发送自己的数据包就收到了新的TCP数据包,导致错误计算)。

顺便安利一下Jonhoo大佬的Youtube频道,内容主要是Rust相关,极其硬核。他本人之前是MIT分布式系统课程6.824的助教,也是MIT MissingSemester的讲师之一。

Usage

我们的程序依赖于TUN/TAP,需要一些额外的工作,所以我们使用脚本来辅助运行,就不直接提供二进制程序啦,感兴趣可以clone项目然后运行这个脚本即可。不过我只在Linux下测试,其它系统是否可用未知。

./run.sh

现在项目的main.rs中的代码实现了一个通信和echo服务,可以通过netcat命令连接,支持多个连接,效果如下。

解释一下我们脚本做了什么:

  1. 首先当然是使用release选项来编译。
  2. 然后需要对我们编译后的程序赋予特殊权限,让我们在以普通身份运行它时可以操作网卡。具体可以查看文档: man 7 capabilities
  3. 接下来给我们的虚拟网卡设置一个IPv4地址,并启用网卡。
  4. 最后运行我们的程序。

请注意第三四步。赋予IP地址的操作需要一个网卡设备,这个设备只有我们的程序运行之后才会出现。为什么不交换三四步呢?因为交换之后我们的程序就不能获得STDIN了。所以第三步操作需要延迟一下,让它在我们的程序运行后再执行,可以使用sleep来实现异步执行。

如果不需要捕获STDIN,可以交换三四步,而且不用sleep,不过记得使用trap来捕获ctrl c或者其它键盘信号,不然没法终止程序。

项目笔记

接下来写代码时的一些笔记~

使用的工具

  1. wireshark来抓包,不过在刚开始的时候不太关心应用层的数据,所以直接用cli工具tshark来抓包。如果需要解析8进制的ip数据包,使用https://hpd.gasmi.net/
  2. 使用netcat作为客户端来测试TCP连接过程。
  3. 由于我们仅仅是为了学习TCP协议,并不关心网络数据包的捕获和发送方式,就不用BPF或者DPDK等工具了。我们使用的是TUN/TAP,用它来创建虚拟网卡完成捕获和发送TCP/IP数据包,所以我们的项目是用户态TCP协议。
  4. 同样,我们也不关心大小端、序列化和反序列化等TCP/IP数据包的解析,这个过程使用的是其它etherparse这个Crate。但是对于TCP/IP header的格式还是需要熟悉才行。
  5. 我们使用了poll轮询来检查是否有新数据包,所以使用了nix这个crate
  6. 使用的一个简单的日志工具logsimplelog
  7. 为了实现一个双向通信的应用,需要用到crossbeam_channel

这里有个比较奇怪的地方就是不能用调试工具,LLDB会导致我的TUN/TAP虚拟网卡退出,原因未知。所以只能print debug啦,这就是为啥用了日志工具。

项目结构

在某个连接处于Established状态下的TCP流程示意图1所示:

图1. `Establish`状态下的`packet`处理过程

我们这里没有使用channel类型来做线程间通信。如果用channel的话,这是一个多生产者多消费者通道,可以使用crossbeam这个crate,不过这会导致多次数据的复制,所以这里使用了另外的处理方式。

此时我们有一个Nic网卡实例,这个实例中需要一个packet_loop无限循环来完成数据的持续收发,为了不阻塞我们的程序,这需要创建为一个单独的线程。我们将TCP连接相关的处理放在这里,那么这个循环会做三件事情:

  • 我们有一个ConnectionManager (CM)来管理所有的连接,那么这个循环首先会检查CM中是否有标记为removed的连接,有的话将其从CM中销毁。
  • 接下来会使用poll来轮询是否有数据抵达nic。如果没有,则会让CM中的每个连接调用on_tick方法发送或者重传需要的被发送的TCP数据包。
  • 根据前面poll的结果,如果有数据到达并且是TCP数据,就将其引用保存到这个socket对应的连接的receive space,并根据TCP协议来调整连接的TCB,然后唤醒用户读取数据;如果没找到socket对应的连接,应该将这个socket放到一个等待队列中,然后唤醒Listener来创建一个新的连接。

在上一步中,每个连接相关的方法被调用时都需要获取Nic的锁,保证TCP处理过程中数据包本身没有改变。

然后为了让我们的TCP协议可用,我们需要实现简单的socket接口,我们这里只实现了server端的被动打开,所以至少需要bind, accept, read, write, shutdown几种方法。

  1. bind:创建一个Listener,绑定在某个nic的端口;
  2. accept:由Listener调用,创建新的连接。为了能够持续创建新的连接,这个accept内部是一个循环,不断从等待队列中拿取socket创建新连接并交给ConnectionManager
  3. read:在创建一个连接后,等待packet_loop唤醒,然后读取数据。数据的持续读出交给了应用层实现。
  4. shutdown:主动或者被动关闭连接。由于我们没有实现Listen状态,所以连接进入closed状态后会被直接从ConnectionManager中删除。

接下来的内容是我完成本项目过程中的一些笔记。

TCP协议相关

TCP初始化阶段

首先需要建立一个用于监听网卡的接口nic,这时一个链路层的接口,它将会负责所有的网络数据包的接收和发送,其中自然也包括tcp连接,此时需要一个无限循环来保证nic的接收和发送的持续性,否则我们在接收一个数据包之后程序就会终止。

nic是一个链路层的接口,那么我们这个接口通信用到的是ip协议,我们这里不关心ip协议的细节,直接使用etherparse这个rust crate来提取或者封装得到的tcp packet

这里需要注意MTU的限制,这里假设MTU为固定的1500字节,所以我们每次从nic读取数据包的长度便硬编码为1500字节。封装数据包的时候也要注意MTU检测每个ip packet的长度是否超过了1500字节,不过我们之后初始化tcp连接时会直接将我们的发送窗口硬编码为1024(当然依旧需要检查数据包的长度)。MTU和发送窗口在实际情况中大小可以是动态变化的,例如TCP协议的各种拥塞控制算法就是用来调整发送窗口大小的,不过这不是RFC 793解决的问题,所以之后我们的实现中也没有拥塞控制(注意滑动窗口和拥塞控制关心的不是同一件事情)。

nic中可能包含很多不同的tcp连接,可以使用对方ip,对方端口和我们的端口这样一个三元组来辨别不同的tcp连接。所以可以使用一个HashMap来保存这些连接,那么键值分别为三元组和Transmission Control Block。为了发送tcp packet,我们需要知道对方主机地址和端口号;为了正确读取tcp packet,我们还要知道这个tcp packet的目标端口号。所以为了使用方便,键的结构可以是一个包含两个"socket"的元组:((client addr, client port), (our addr, our port)),这些信息可以在ip headertcp header中获取。

连接的被动打开

  1. 当我们收到一个tcp packet时(收到握手请求),需要判断这个一连接是否是新的连接还是已经存在的连接,如果是新的连接,那么需要在保存连接的HashMap中建立一个新的连接,即实现一个establish的方法。

    首先读取tcp headerControl Bits里的SYN是否为1,如果不是的话就直接忽略这个packet。 1.1 如果上一步SYN为1,这时需要建立一个新的连接,具体来说也就是初始化一个新的socketTransmission Control Block (TCB),然后将其作为socket pair的值插入HashMap中。TCB中的数据会被用在保证TCP可靠性的各种计算中,具体参考RFC 793 page 19。注意此时我们的状态为SYN-RECEIVED

  2. 接下来需要回应对方,表示这个接收到了这个连接请求。 即我们需要发送一个tcp packet,此时需要构建合适的tcp header。这实际上属于二次握手的内容,但由于在这一步方法是一次性使用的,那么在这里同时设置好这个header就可以避免在其它方法中做额外的工作。这个header需要设置SYNACK,以及我们的窗口信息、序列号等。 然后发送这个SYN ACKtcp packet,发起第二次握手。

    为了发起这次握手,我们首先需要对自己的TCB做出合理的修改,然后要设置一个合理的TCP/IP header交给nic。这两个步骤可以用一个方法write方法来完成。为了避免冗余,这个方法需要在其它阶段也可以使用。

    tcp header的内容需要根据tcp header format(RFC793 page 15)来设置,单实际上很多内容已经被包含在了这个连接的TCB中,可以直接使用,例如端口号,序列号,窗口大小等。一定要不能忘记checksum需要单独计算。

  3. 在发起二次握手后,等待对方回应ACK了(对方发起第三次握手)。接下来就可以进入ESTABLISHED状态啦。

以上是被动打开的情况,我们这里没有完成主动打开。所以也就没有实现同时发起连接,不过在我们的实现中是可以正常处理同时发起连接时的数据包的,只是这种情况不会发生:wink:。

  1. 建立连接的时候需要注意一些问题
    • passive OPEN:当我们解析一个packet时,发现它拥有未知的地址和端口(unspecified foreign socket),此时我们就需要创建一个新的TCBTCB状态设置为Listen。 注意可能同时有很多这样的新TCB,所以需要一个pending的数据结构来存储这些TCBs,然后交给另外的线程处理TCBsClosed同理。不过我们这里在接收到一个新packet后,直接处理而不是放入等待队列,所以不用设置Listen状态,因为这个接下来,要么将包对应的TCB标记为SYN-RECV,要么直接删除TCBClosed同理,连接4次挥手之后(TMIEWAIT也结束),如果直接删除TCB,所以也不用设置Closed状态。

      • Listen状态是有意义的。在dup SYNHalf-Open的情况下,连接在接收到RST后,TCB状态会变为Listen而不是被删除,并在接下来会再次握手来恢复连接。
    • 另外,握手阶段的tcp packet只有header没有data

    • 在发起第二次握手的write方法完成之后,连接的状态依旧是SYN-RECEIVED,需要等待对方回应之后才能转变为ESTABLISHED,但是在这之前是没有超时重传的,这里的TIMEOUT会交给应用层。

    • 我们发送的数据长度不能超过MTU,所以数据可能需要被分成多个segment发送。每个segment是一个单独的tcp packet,它们在传输的过程中可能丢失也可能乱序抵达目标,所以对于发送数据这一过程来说,我们可能暂时没有收到对已经发送了的数据包的ack信息。那么我们需要对接来要发送的数据包设置正确的sequence number。同时由于unack packets的存在和对方接收窗口的限制,我们所允许发送的数据长度也需要调整,tcp header中的sequence number的设置需要参考RFC793 page 19的关于Send Sequence Space的定义。

    • 由于tcp header中sequence number只有32bits,所以需要对数值溢出后进行处理,不过rust数值类型有一个很方便的wrapping_add()的方法。

    • 注意第三次握手时的ACK数据包,也就是ESTABLISHED之前这次握手。这个数据包是可以携带数据的,甚至可以是FIN,所以从这一步开始就要处理数据了。

    • TCP协议认为FIN的长度是1,所以如果接受到一个FIN数据包,即使payload长度为0,也要修改TCB中序列号相关的字段。

通信过程

segment arrives

建议参考RFC page 65的说明,然后看代码,挺好理解的,只是内容和细节比较多。 这里主要解释一下sequence number的范围的处理。

SEG.SEQ 表示接收到的包的sequence number,那么我们可以计算出这个数据包最后一个sequence number的值应该是SEG.SEQ+SEG.LEN-1。这两个sequence number的范围应该满足如下两个条件之一:

1. RCV.NXT =< SEG.SEQ < RCV.NXT+RCV.WND
2. RCV.NXT =< SEG.SEQ+SEG.LEN-1 < RCV.NXT+RCV.WND

这两个条件分别测试数据包起始序列号和结束序列号是否处于接收窗口范围内,满足任意一个即可(这是因为假设了连接双方都会遵守TCP协议,那么只要满足一个,根据协议可以认为另一个条件自然也会满足)。

这里需要注意tcp headersequence number是长度为32位的二进制数,那么范围在[0, 2**32 -1],为了表示出无限增长的序列号,tcp header中相应的字段会在这个范围内循环。所以在实现tcp协议的时候,要处理sequence number + data.len > 2**32 - 1的情况,即数据包的起始序列号加上数据包长度超过了上限。为了处理这种情况,RFC 793要求对于序列号的计算都要用序列号对2 ** 32取模后再使用,不过在rust中用wrapping_add这个方法处理这种情况。

send call

我们实现一个write方法来实现发送数据,这在前面有过说明,但是注意在通信阶段需要为长度不为0的数据包设置一个定时器。

timer

我们设置一个on_tick方法来完成从发送队列读取数据然后调用protocol::write的工作,同时需要检查数据包的超时重传。步骤如下:

  1. protocol::writetcp packet发送到网卡的时候,使用一个BtreeMap来保存这个packet的序列号和发送时间,并将这个tcp连接的una设置为这个序列号。
  2. timerunacked队列拿数据时,会先检测una这个序列号对应的packet是否已经超时(当前时间与BtreeMap中保存的时间之差大于TIMEOUT)。显然超时的话就重新发送这个包,此时这个包的发送时间会被protocol::write重置。 如果没有超时的packet,则发送send.nxt。发送的工作有protocol::write完成。此外srtt的更新也是在这里完成的。

close

TCB中需要设置两个字段来完成连接的正确关闭,用closed表示连接状态已经改变,我们需要发送FIN。一个是closed_at,我们已经发送了FIN。这样做是因为状态改变和发送数据发生在不同过程中,中间会释放锁。

retransmit

我们这里的实现类似于停-等方式,即假如我们没有收到上一次发送的segmentACK消息,那么我们就不会发送新的数据,所以我们需要重传的数据只有一段。注意这里的数据指tcp packet中的payload长度大于0。我们依旧可以正常ACK对方发送过来的segment,我们的ack number需要根据实际情况变化,但是sequence unmbersend.nxt不会变化,对方的接收窗口即我们的发送窗口send.wnd由于没有收到数据所以也不会变化。我们这里重传就很简单,只需要根据seq numbersend.nxt从我们的unacked队列中重新发送相应的数据就可以了。

接口设计

我们也要简单处理一下多线程的问题C10kC10k的解决方法有很多,例如多线程、同步/异步阻塞/非阻塞IO复用等。我们这里使用io复用的方式来处理多线程。

网卡接口

网卡nic负责数据的收发,需要使用一个无限循环来保证持续监听数据。

  1. 收发和发送数据。这是接口的最基本功能,我们这里和网卡通信的数据包协议是Ipv4协议,暂时不处理Ipv6数据包。这时就要使用一个额外的线程完成这个工作,也就是图1中的packet loop。这个线程在初始化网卡时同时被创建。
  2. 提供一个锁,也就是Arc。我们认为网卡在任何时刻只能被一个连接使用。

所以根据以上考虑,网卡接口的数据结构由二元组构成,分别是JoinhandleArc<...>

tcp连接需要指定端口,所以需要实现一个类似socket编程中的bind方法,用于为TcpListener指定端口。因此,我们设计的网卡接口至少需要实现三个方法:

  1. new方法或者default Trait,用于初始化网卡接口。
  2. bind方法,为tcp应用绑定一个端口。这个方法应该返回一个TcpListener结构体。
  3. Drop Trait, 则负责程序终止时的清理

此外,连接管理器ConnectionManager(CM)这个结构体也在这里被创建。CM由两个结构体组成,connectionsocket pairconnection对应的是TCP协议中的连接部分,pending中的socket pair则是交给Listener来创建StreamStream更接近于应用层的概念,connectionStream是一一对应的关系。当Stream需要读取或者写入的时候,需要connection的方法来实现。

TcpListener

在我们的设计中,nic负责在数据到来后唤醒Streamread方法或者创建新的连接。

创建新连接的工作由TcpListeneraccept方法来实现。

如果packet_loop发现数据包不存在于当前CM中,需要创建新的connection。如果创建成功,则将这个connectionsocket放到CMpending结构体末尾,并唤醒TcpListener来从pending读取连接信息创建新的Stream

ConnectionManager通过一个HashMap管理着所有connectionconnection中保存着TCB信息。另外还需要通过一个HashMap保存未被创建为StreamSocketPair

Read

我们的read逻辑是,当incoming为空时,一直阻塞read线程直到被唤醒;当incoming不为空时,循环读出直到incoming为空。 main需要一直循环创建stream::read直到连接关闭,不这样做的话会导致packet_loop中的Action::READ没有唤醒对象,进而阻塞。

Write

这是一个socket接口,将用户数据交给connectionunacked队列,这个数据需要被缓存到队列而不是直接发送是为了超时重传。

Shutdown

我我们调用shutdown这个函数来发送FIN。将TCB中的closed设置为true。 注意当我们在ESTABLISH的状态接受到FIN处于CLOSE WAIT状态,此时仍然可以正常发送数据。只有当我们调用close()后,我们才发送FIN进入LASTACK

其它笔记

header中序列号的循环计数问题

解决方案来自RFC 1323

From RFC1323: TCP determines if a data segment is "old" or "new" by testing whether its sequence number is within 2^31 bytes of the left edge of the window, and if it is not, discarding the data as "old". To insure that new data is never mistakenly considered old and vice-versa, the left edge of the sender's window has to be at most 2^31 away from the right edge of the receiver's window.

rust对应的代码如下:

fn within_window(lhs:u32, rhs:u32) -> bool {
    lhs.wrapping_sub(rhs) > (1 << 31)
}

注意这个1 << 31的原因。RFC 1323为了实现窗口扩大选项(windows scale option)和拥塞控制Congestion Control以及对应的计算,在TCB中使用了32 bits的变量来保存窗口大小(包括发送、接收和拥塞窗口)。在序列号的循环计数时,为了保证新旧header中序列号不被混淆,要求发送者窗口左边界(ackn)与接收者窗口的有边界(rcv.nxt + rcv.wnd)的距离不能超2^31

RFC 793中的建议是将所有参与计算的值mod 2**32,但这在实际编码的时候还是需要做一堆比较。所以为了方便,我们还是用RFC1323这个标准好了....

乱序重组

tcp packet在传输过程中可能丢包,超时重传机制只保证在丢包时重新发送,并不能保证tcp packet按顺序抵达。不过我们这里并没有实现SACK等,所以不用考虑乱序重组。 RFC 793中在接收时,只要求segment在接收窗口内就可以了,在给出的例子中,假设了接收到的segment number总是会等于RCV.NXT,也就不会乱序。对于segment number大于RCV.NXT的情况(也就是说这个包“提前”到了),只是说了Segments with higher begining sequence numbers may be held for later processing.

Tips

  • ip header 中的ihl指的是ip header的长度,但是单位是32bits,所以使用的时候一般要乘以4来得到bytes或者32来得到bitsihl最小值是5,最大值是15,也就是说ip header的最短长度为20bytes或者160bits(此时option字段为空),最大长度为60bytes或者480bits

  • 当我们用nc测试发送字符的时候,回车键LF字符也会被发送出去

  • TUN frame format:

flags -> 2 bytes (IFF_TUN, IFF_TAP, IFF_NO_PI, basically tun device info)
proto -> 2 bytes (frame type, like IP, IPv6, ARP..., keyword: ether type)
raw protocol frame (IP package, etc. 46~1500 bytes, MTU = 1500 bytes and here is used for IP package in network layer, MTU is not fixed and is set by linux up to 65535 )
MTU in link layer may larger than 1500 due to CRC.
  • 大小端
network -> big endian -> u16::from_be_ending
x86_pc -> little endian
  • Ethernet MAC frame
target MAC -- source MAC -- type(ARP/IP/...) -- data -- FCS(校验)
  • data type
in the ip stream is octect of [u8] in rust, which should use vec or slice.
data type of the port number and window size are u16, see TCP Header Format
data type of the seq/ack number, nxt, una... are u32, see TCP Header Format
data type of the length of the data are usize
  • ctrl-c cannot break loop:

    bash implements WCE(wait and cooperative exit) for SIGINT and SIGQUIT bash will wait the process exists and then exit bash itself. bash will exit only if the current running process dies of SIGINI or SIGQUIT.

    solution: use trap

About

RFC 793 by Rust

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published