cs144-lab2: 简介: lab2 的目的是继续完善 tcp 的细节,我们之前在 lab1 中实现的部分已经模拟了 ack 的逻辑,例如接收窗口什么的,但是某一些细节部分被忽略了,例如上一节的序列号和确认号——由于 TCP 序列号是 32 位无符号整数,最大值是 4294967295。当序列号达到这个最大值后,会“回绕”到 0,继续递增。这种现象称为序列号回绕 (Sequence Number Wraparound)。
在低速网络中,序列号回绕可能需要很长时间才会发生。例如,传输 4GB 数据(2³² 字节)在 1Mbps 的网络上需要大约 8 小时。
但在现代高速网络中(如 10Gbps 或更高),序列号回绕可能在几秒钟内发生。例如,在 10Gbps 的网络上,传输 4GB 数据只需约 3.2 秒。
现代 tcp 中引入了 Tsval 和 Tsecr 等段来区分,这个可以在 wireshark 里抓的包看到。
从而我们可以基于此计算出对应的是否是发生了回绕的序列号。
类似的检测我们需要在 wrapping 中实现,将三种序列号的关系对应起来,用文档里的话来说的话,就是将序列号转化为绝对序列号,绝对序列号始终从零开始并且不存在回绕, 而流索引则代表着流中每个字节的索引(在 Reassembler 中我们已经用到了),以输入 ‘cat‘ 的字节流为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 #pragma once #include <cstdint> class Wrap32 {public : explicit Wrap32 ( uint32_t raw_value ) : raw_value_( raw_value ) { } static Wrap32 wrap ( uint64_t n, Wrap32 zero_point ) ; uint64_t unwrap ( Wrap32 zero_point, uint64_t checkpoint ) const ; Wrap32 operator +( uint32_t n ) const { return Wrap32 { raw_value_ + n }; } bool operator ==( const Wrap32& other ) const { return raw_value_ == other.raw_value_; }protected : uint32_t raw_value_ {}; };
在绝对序列号和流索引之间进行转换足够简单 —— 只需加一或减一即可。不幸的是,在序列号和绝对序列号之间进行转换要困难一些,混淆两者可能会产生棘手的错误。为了系统地防止这些错误,我们将使用自定义类型: Wrap32 来表示序列号,并编写它与绝对序列号(用 uint64 t 表示)之间的转换。
具体实现: 这里的实现有点类似于初中时学绝对值的感受,先去除掉 zero_point 的影响,然后计算出 base 值,这里有一个巧妙的处理,利用位运算:
1 uint64_t base = checkpoint & ~(0xFFFFFFFFULL )
这样可以非常高效的去除掉低 32 位,因为当 checkpoint 的序列号大于 2^32 后,每隔 2^32 就开始重新循环,所以我们需要找到最接近的当前周期。
比如:
如果 checkpoint = 4,294,970,000 (接近一个周期末尾)
清除低32位后得到 base = 0
这样我们可以构建当前周期、上一周期和下一周期的候选值
当然,位运算是我最后让 Claude 检查的时候它给我的建议,我本人只会傻傻的除法 : (,但是这也从侧面证明 ai 真的是很棒的老师。
参考代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 Wrap32 Wrap32::wrap ( uint64_t n, Wrap32 zero_point ) { uint32_t offset = (n + zero_point.raw_value_) % (1ULL <<32 ); return Wrap32 { offset }; }uint64_t Wrap32::unwrap ( Wrap32 zero_point, uint64_t checkpoint ) const { uint32_t offset = raw_value_ - zero_point.raw_value_; uint64_t base = checkpoint & ~(0xFFFFFFFFULL ); uint64_t now = base + offset; uint64_t next = now + (1ULL <<32 ); uint64_t pre = now - (1ULL <<32 ); uint64_t distance1 = now >= checkpoint? now - checkpoint : checkpoint - now; uint64_t distance2 = next >= checkpoint? next - checkpoint : checkpoint - next; uint64_t distance3 = pre >= checkpoint? pre - checkpoint : checkpoint - pre; if (distance1 <= distance2 && distance1 <= distance3) { return now; } else if (distance2 <= distance1 && distance2 <= distance3) { return next; } else { return pre; } return {}; }
TCPReceiver: 我们已经实现了序列号的封装和解封装逻辑,接下来就需要真正的去实现 TCPReceiver 了,指导书上给出的结构如下:
我们需要对发过来的 message 进行处理,先看一下 message 的结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 struct TCPSenderMessage { Wrap32 seqno { 0 }; bool SYN {}; std::string payload {}; bool FIN {}; bool RST {}; size_t sequence_length () const { return SYN + payload.size () + FIN; } };
基于 message 的结构我们可以思考我们需要对 Receiver 设计怎样的私有类。
我们需要明确 TCPReceiver 有几种状态:
回想一下 tcp 还没建立连接时,服务端会处于监听模式,这个时候我们的随机序列号还没有确定。
当收到 syn 后,还没收到 fin 字段前,都应该是接受字节流的模式
收到 fin,自然就关闭了。
所以我们可以这样设计:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 class TCPReceiver {public : explicit TCPReceiver ( Reassembler&& reassembler ) : reassembler_( std::move( reassembler ) ), is_syn_received_( false ), is_fin_received_( false ), has_error_( false ), zero_point_( std::nullopt ) { } void receive ( TCPSenderMessage message ) ; TCPReceiverMessage send () const ; const Reassembler& reassembler () const { return reassembler_; } Reader& reader () { return reassembler_.reader (); } const Reader& reader () const { return reassembler_.reader (); } const Writer& writer () const { return reassembler_.writer (); }private : Reassembler reassembler_; bool is_syn_received_; bool is_fin_received_; bool has_error_; std::optional<Wrap32> zero_point_; };
分别对于三种状态做处理,这里唯一注意的点是流索引相较,绝对序列号要去除 syn 的影响。
实现还是比较简单的,难点在于将前面封装好的东西,如何去调用,所以我觉得这里最重要的是明确你在调用什么。
所以在实现之前最好整理一下思路,我们目前为止究竟实现了些什么东西,它在 tcp 中又对应什么。
回忆: 我们最开始实现的是 ByteStream,表示底层的数据流,分别对应 Writer 和 Reader 两端,用于缓冲数据并且控制流,也就是最底层的字节流。
之后实现的 Reassembler 则是处理 tcp 乱序的关键,类似于 tcp 的缓冲接受区,保存未按序到达的数据,同时将数据流组织有序后,发送给应用层。
而 Wrap32 类则模拟了 tcp 序列号系统,处理序列号只有 32 bit 可能会发生回绕的问题,封装的 wrap 和 unwrap 则是为了解决绝对序列号问题。
而我们现在要实现的 TCPSenderMessage 和 TCPReceiverMessage 则分别是模拟 tcp 段和 tcp 的确认,通过模拟以下的消息结构来达到实现 tcp 协议栈中接收方的作用:
梳理清楚后,再看一看对应头文件中定义的方法就可以开始实现了。逻辑很简单,主要是如何去调用之前封装好的内容…
参考代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 #include "tcp_receiver.hh" #include "debug.hh" using namespace std;void TCPReceiver::receive ( TCPSenderMessage message ) { if (message.RST){ is_syn_received_ = false ; is_fin_received_ = false ; zero_point_ = std::nullopt ; has_error_ = true ; reassembler_.reader ().set_error () ; return ; } if (message.SYN){ if (!is_syn_received_){ is_syn_received_ = true ; zero_point_ = message.seqno; } } if (!is_syn_received_ || !zero_point_.has_value ()){ return ; } uint64_t checkpoint = reassembler_.writer ().bytes_pushed (); uint64_t abs_seqno = message.seqno.unwrap (zero_point_.value (),checkpoint); uint64_t stream_index = abs_seqno; if (message.SYN){ stream_index = 0 ; } else { stream_index = abs_seqno - 1 ; } if (message.FIN){ is_fin_received_ = true ; } if (!message.SYN && abs_seqno == 0 ) { return ; } reassembler_.insert (stream_index, message.payload, message.FIN); }TCPReceiverMessage TCPReceiver::send () const { TCPReceiverMessage response {}; if (has_error_ || reassembler_.reader ().has_error ()) { response.RST = true ; response.ackno = nullopt ; return response; } uint64_t window_size = reassembler_.writer ().available_capacity (); response.window_size = min (window_size, static_cast <uint64_t >(UINT16_MAX)); if (!is_syn_received_ || !zero_point_.has_value ()) { return response; } uint64_t abs_ackno = reassembler_.writer ().bytes_pushed () + 1 ; if (reassembler_.writer ().is_closed ()) { abs_ackno += 1 ; } response.ackno = zero_point_->wrap (abs_ackno, *zero_point_); return response; }
30 个测试点,懒得截长图了,就这样吧。
目前做下来,感觉对于调用封装好的能力有待加强(其实是隔一段时间做就忘了前面干了啥…)。