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 来进行纠错检测等等操作,来保证数据的正确传输。