CS144番外

由于之前赶着做CS144,一直没有把Minnow的源代码好好看看,现在有时间了,准备将代码逻辑缕缕,看看是如何将包从发送方发出,并一步步到达接收方。

本人仓库代码

首先从TCP发送方接收方的耦合开始。

checkpoint 0

webget.cc 这个文件引用了 socket.hh头文件,查看一下。

首先定义了一个Socket类,通常不直接使用该类,而是使用其子类TCPSocket或者UDPSocket。注意Socket类的基类是 FileDescripter。

Socket 中定义了一些常用的方法(如构造函数),并用protected修饰,只能由子类或者该类的友元类和友元函数访问,不能被外界代码访问。

  • 默认构造函数
1
2
3
4
5
6
// default constructor for socket of (subclassed) domain and type
//! \param[in] domain is as described in [socket(7)](\ref man7::socket), probably `AF_INET` or `AF_UNIX`
//! \param[in] type is as described in [socket(7)](\ref man7::socket)
Socket::Socket( const int domain, const int type, const int protocol )
: FileDescriptor( ::CheckSystemCall( "socket", socket( domain, type, protocol ) ) ) // 这里是在初始化基类
{}

其中 domain描述了沟通的域, 即协议家族,常见的有 AF_UNIX (用于本地通信)和 AF_INET (用于IPV4通信)。

type描述了沟通语法。例如:SOCK_STREAM 支持有序,可靠,双工,连接的字节流,可能支持外带数据的传送机制。

SOCK_DGRAM 支持 数据报( 无连接,固定最大长度的不可靠报文)

protocol描述了描述了套接字使用的特别的协议。多数情况,协议家族只支持一种协议,这样就将protocol置0,少数情况下,就需要描述使用的协议类型。

  • 使用文件描述符构造

与上面类似

不过使用确定的文件描述符来构造套接字。

然后就是其他一些成员函数,获取地址,监听端口等等。

checkpoint 4

还记得 我当时写这个Lab时,将webget的头文件添加了 \#include "tcp_minnow_socket.hh",让我们一起剖析一下这个头文件。

这里面定义了 CS144TCPSocket(继承自 TCPMinnowSocket) , 由于webget.cc 中使用的就是这个,说明它集成了我们所有的实现。

这个文件引用了 tcppeer。

TCPPeer类,模拟对等体,将TCPSender和TCPReciver结合起来,里面还出现了TCPMessage(包括将报文段和确认报文一起发送,记住TCP是双向的,这是捎带确认)。

声明了 TCPMinnowSocket类,继承自LocalStreamSocket类。

TCPMinnowSocket 是TCPPeer类的多线程封装,模拟Unix套接字。

构造函数使用了适配器TCPOverIPv4OverTunFdAdapter(使不兼容的对象能够相互合作)。

通过该适配器,读取和写入TUN设备TUN设备用于内核网络协议栈(链路层)和用户空间之间传递数据是虚拟的网络接口。使得链路层及以下变得透明。

TUN设备

TCPOverIPv4OverTunFdAdapter有一个成员变量 TunFD ,这是TUN设备的文件描述符,这样我们就可以在用户态将IP数据报度写入内核。

再看看 CS144TCPSocket 的 connect 函数:

1
2
3
4
5
6
7
8
9
10
11
void connect( const Address& address )
{
TCPConfig tcp_config;
tcp_config.rt_timeout = 100;

FdAdapterConfig multiplexer_config;
multiplexer_config.source = { "169.254.144.9", std::to_string( uint16_t( std::random_device()() ) ) };
multiplexer_config.destination = address;

TCPOverIPv4MinnowSocket::connect( tcp_config, multiplexer_config );
}

首先定义了TCPConfig,用于 配置TCP的参数。通过FdAdapterConfig 配置原地址和目标地址信息。然后使用父类 TCPOverIPv4MinnowSocket(TCPMinnowSocket)的connect函数,继续连接目标地址。

在TCPMinnowSocket的connect的函数中将发送者的transmit函数设置为向适配器写入的函数,这样当发送方发送时,实际上是写入到TUN设备中。

那么接收方是如何从TUN设备读取数据呢?

找到了部分代码

1
2
3
if ( auto seg = _datagram_adapter.read() ) {
_tcp->receive( std::move( seg.value() ), [&]( auto x ) { _datagram_adapter.write( x ); } );
}

这里通过从TUN设备中获取数据,再将其交给TCPPeer。

至此我们弄明白,CS144TCPSocket通过使用TCPPeer(简单理解为我们写的代码的集合),构造TCP报文段,通过适配器转化为IP报文并写入TUN设备(也是通过文件描述符)中,接下来的所有事就交由给内核处理。 反之则从TUN设备中读取IP报文段,转化为 TCP报文段,然后通过TCPPeer转化为字节流,交付给上层进程。

TCPMinnowSocket中的两个线程

TCPMinnowSocket 和 正常的TCPSocket的区别:

  • 只能接收单个连接
  • listen_and_accept 是 listen和accept的结合
  • 如果在TCP连接时被销毁,会直接发送RST终止,但可以通过wait_until_close 来避免,从而四次挥手。

一个前台线程,使得和TCPMinnowSocket交互就像和TCPSocket交互一样: 连接,侦听和从可靠字节流(TCP)中读取。

另一个是后台(TCPPeer)线程,负责处理内核为TCPSocket所执行的后台工作: 读取和解析网络上的数据报,过滤无关报文段等。

后台线程 监听事件通过eventloop完成,eventloop在TCP初始化中加入了三种事件的规则:

  • 接收到的数据报,经过适配器的解析,交给TCPPeer, 然后TCPPeer将其分解为TCPSenderMessage和TCPReciverMessage ,TCPSenderMessage交给 TCPReciver 处理(Reassembler, 等等), TCPReciverMessage交给 TCPSender处理。
  • 从本地程序收到的外出比特, TCPMinnowSocket 的 描述符和_thread_data (用来进程间传送数据) 是一对LocalStreamsocket。外部程序向CS144TCPSocket 写入,就会传送(本质上还是TCP)到 _thread_data,从而后台线程就可以读取。
  • 接收到的被Reassembler重组的字节流,同样需要写入thread_data然后被应用程序读取。

我们实现的TCP,仅支持监听和接收一条线路,不支持多路复用和排队。

TUN设备会接收哪些IP数据报?

查看tun.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
TUN_IP_PREFIX=169.254
start_tun () {
# 第一个参数作为TUNNUM,是设备编号,命名为tun#
local TUNNUM="$1" TUNDEV="tun$1"

# 添加tun设备
ip tuntap add mode tun user "${SUDO_USER}" name "${TUNDEV}"
# 给 tun144 配置ip地址
ip addr add "${TUN_IP_PREFIX}.${TUNNUM}.1/24" dev "${TUNDEV}"
# 启动tun设备
ip link set dev "${TUNDEV}" up

# 配置流经tun设备的目标网段
ip route change "${TUN_IP_PREFIX}.${TUNNUM}.0/24" dev "${TUNDEV}" rto_min 10ms

# Apply NAT (masquerading) only to traffic from CS144's network devices
# 为tun设备的数据在路由前打标签,现在linux服务器相当于路由器实现转发功能。
iptables -t nat -A PREROUTING -s ${TUN_IP_PREFIX}.${TUNNUM}.0/24 -j CONNMARK --set-mark ${TUNNUM}
# 刚才打标签的数据报伪造成服务器ip,实现nat
iptables -t nat -A POSTROUTING -j MASQUERADE -m connmark --mark ${TUNNUM}
# 让服务器像路由器一样,转发接收到的数据报。
echo 1 > /proc/sys/net/ipv4/ip_forward
}

checkpoint 7

image-20250214100950522

服务器和客户端,通过中继服务器连接。

如果没有中继服务器,那么服务器和客户端在同一个NAT网络下,能否进行交互呢?

答案应该是不能,因为一个是在192.168.0.1网段下的,另一个是在172.16.0.1 网段下的,而路由器不能识别这些ip源地址。

所以在这部分中,使用UDP将以太网帧封装,才能发送出去。

配置路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if ( is_client ) {
host_side = router.add_interface( make_shared<NetworkInterface>(
"host_side", router_to_host, random_router_ethernet_address(), Address { "192.168.0.1" } ) );
internet_side = router.add_interface( make_shared<NetworkInterface>(
"internet side", router_to_internet, random_router_ethernet_address(), Address { "10.0.0.192" } ) );
router.add_route( Address { "192.168.0.0" }.ipv4_numeric(), 16, {}, host_side );
router.add_route( Address { "10.0.0.0" }.ipv4_numeric(), 8, {}, internet_side );
router.add_route( Address { "172.16.0.0" }.ipv4_numeric(), 12, Address { "10.0.0.172" }, internet_side );
} else {
host_side = router.add_interface( make_shared<NetworkInterface>(
"host_side", router_to_host, random_router_ethernet_address(), Address { "172.16.0.1" } ) );
internet_side = router.add_interface( make_shared<NetworkInterface>(
"internet side", router_to_internet, random_router_ethernet_address(), Address { "10.0.0.172" } ) );
router.add_route( Address { "172.16.0.0" }.ipv4_numeric(), 12, {}, host_side );
router.add_route( Address { "10.0.0.0" }.ipv4_numeric(), 8, {}, internet_side );
router.add_route( Address { "192.168.0.0" }.ipv4_numeric(), 16, Address { "10.0.0.192" }, internet_side );
}

和上面图片一样。

配置客户端或者服务器

1
2
3
/* set up the client */
TCPSocketEndToEnd sock = is_client ? TCPSocketEndToEnd { Address { "192.168.0.50" }, Address { "192.168.0.1" } }
: TCPSocketEndToEnd { Address { "172.16.0.100" }, Address

一个地址为其ip地址,另一个是下一跳地址。

然后设置网络的各种事件:

  • 主机到路由器
  • 路由器到主机
  • 路由器到网络
  • 网络到路由器 ,然后转到网络接口,再由路由器网络接口转入主机网络接口,再由适配器读入,然后TCPMinnowSocket读入,和之前一样。

新定义的TCPSocketEndToEnd连接了主机和路由(一对套接字),使用UDPSocket连接 连接远程终端(网络)。

1
2
3
4
5
6
7
8
9
10
11
12
while ( true ) {
if ( EventLoop::Result::Exit == event_loop.wait_next_event( 10 ) ) {
cerr << "Exiting...\n";
return;
}
router.interface( host_side )->tick( 10 );
router.interface( internet_side )->tick( 10 );

if ( exit_flag ) {
return;
}
}

这里为接口记录时间。

可以看出TCP实际传送是通过 TCP-in-IP-Ethernet-UDP 方法。

将其当做 UDP 数据报 进行传输。因为UDP是不可靠传输,如果传输过程中发生损坏,就可以通过我们实现的TCP 来进行纠错检测等等操作,来保证数据的正确传输。