目录
[TCP Receiver](#TCP Receiver)
[Optional 容器](#Optional 容器)
终于是紧赶慢赶把lab2做完了。
其实从lab1就提示了,lab0-lab3四个实验是一个循循渐进的过程,手把手带你搭建一个TCP协议。打算在做完lab3之后再进行一个总的整理,总感觉有些东西做的时候是体会不到的,等完整经历过以后才会有更深的理解。(这就是人无法同时拥有青春和对青春的感悟吗?伤。。。)
其实本实验说的很明白就是做一个TCP receiver,跟着报告走即可。
Wrap
首先迎面走来的是序列号的问题,在lab1我们实现了tcp内部的排序器,但考虑到我们用的是字节流序号,也就是从实际字节的0号开始,但tcp实际用到的是seqno,序列号,他是有初始序列号且回环的。具体看下图可能更清晰点:

我们实现的排序器实际用的是stream index字节流序号,而tcp实际使用的是seqno序列号,实验在中间加了一层转换,新列了一个绝对序列号方便我们在stream index和seqno之间进行转换。
我们的第一个任务就是实现这两者之间的相互转换,首先可以发现absolute seqno和steam index之间就是加减1的关系,那么我们要实现的就是seqno和absolute seqno之间的转换。
这里有一些条件需要注意:在这个小结中我们不需要考虑SYN和FIN的影响,这属于TCP receiver应该考虑的事情,我们只干一个转换的事情。还有我们模拟的是IPv4,所以这里的seqno是32位的,而absolute seqno和stream index在内部存储类型都是size_t,也就是uint64类型,这涉及到数据类型转换的问题。
wrap函数
这里就涉及两个函数,wrap和unwrap,我们先看简单的wrap函数,它接收绝对序列号n和初始序列号isn,要转换成序列号。不考虑位数限制的情况下,那么seqno = absolute + isn。由于seqno是回环的,也就是相当于%2^32,对于64位数字而言,就是取低32位,那么直接取absolute低32位+isn即可。这里直接用类型转换去间接完成取低32位的操作。具体代码如下:
cpp
//绝对序列号转换成序列号seqno
//这里对绝对序号进行强制转换成32位相当于%2^32,符合seqno从0到2^32-1的循环,最后加上isn即可
WrappingInt32 wrap(uint64_t n, WrappingInt32 isn) {
return isn + static_cast<uint32_t>(n);
}
unwrap函数
随后考虑unwrap,这个就比较麻烦了,我们观察函数形参,发现接收序列号n,初始序列号isn和checkpoint检查点,这里为什么要有一个检查点呢?考虑n和isn都是32位,要转换成64位数字,那么实际上只能保证absolute_seqno%32 = n - isn,所以这里有一个辅助参数帮助确定具体是哪一个absolute_seqno。换句话说我们要找一个absolute_seqno,他的低32位等于n-isn,且他离checkpoint最近。
我的方法是:先构造一个粗略的答案,candidate = checkpoint的高32位+(n-isn)。这个答案可能不是正确答案,但正确答案一定是这三者之一:candidate - 2^32, candidate, candidate + 2^32.接下来我们只需要比较这三个哪个才是答案即可。
这里有几个坑点:
- 注意这里n - isn返回类型是int32,所以可能出现负数,而负数强转成uint64时会变成一个很大的数,所以这里要先将n-isn强转成uint32,再强转成uint64.
- 注意这里判断哪个更近时,不能直接简单candidate加减2^32,因为这可能导致越界回环。这里采取的措施判断candidate和checkpoint的距离和2^32/2之间的关系来判断取前面还是后面。
- 注意即使另一个候选者更近,但如果他加减2^32会越界导致回环(超出2^64-1或者小于0),那也不选择,因为absolute seqno绝对字节流没有回环的概念。
具体代码如下:
cpp
//给定序列号seqno、isn和绝对序列号的checkpoint,转换成绝对序列号
//坑点绝对序列号0-最大值之间,不能上溢或下溢
uint64_t unwrap(WrappingInt32 n, WrappingInt32 isn, uint64_t checkpoint) {
//先取出绝对序号的后32位。 !!!注意这里先强转成uint32,再强转成uint64
uint64_t offset =static_cast<uint64_t>(static_cast<uint32_t>(n - isn));
//构造粗略的候选者
uint64_t candidate = (checkpoint & 0xFFFFFFFF00000000) | offset;
uint64_t wrap_const = 1ul << 32;
//还有一个候选者是candidate + 0xFFFFFFFF
if(candidate < checkpoint) {
//这里考虑candidate到checkpoint距离超过一半,则一定是另一个,注意这里是否越界
if(checkpoint - candidate > wrap_const / 2 && candidate + wrap_const > candidate)
return candidate + wrap_const;
}
//还有一个候选者是candidate-wrap_const
else {
//同理
if(candidate - checkpoint > wrap_const / 2 && candidate > wrap_const)
return candidate - wrap_const;
}
return candidate;
}
这样就能通过wrap的测试了,成功截图如下:

TCP Receiver
实现上述Wrap相关后就可以开始实现TCP Receiver了。
我们观察给的TCPReceiver类中成员数据和函数有哪些,发现有StreamRassembler对象,这就是我们lab1实现的排序器,以及容量capacity。
关于成员方法,首先是最关键的segment_received函数,这就是tcp每次接收tcp数据包的函数。这个函数接收TCPSegment对象,
TCPSegment类
我们查找该类的定义如下:
cpp
lass TCPSegment {
private:
TCPHeader _header{};
Buffer _payload{};
public:
//! \brief Parse the segment from a string
ParseResult parse(const Buffer buffer, const uint32_t datagram_layer_checksum = 0);
//! \brief Serialize the segment to a string
BufferList serialize(const uint32_t datagram_layer_checksum = 0) const;
//! \name Accessors
//!@{
const TCPHeader &header() const { return _header; }
TCPHeader &header() { return _header; }
const Buffer &payload() const { return _payload; }
Buffer &payload() { return _payload; }
//!@}
//! \brief Segment's length in sequence space
//! \note Equal to payload length plus one byte if SYN is set, plus one byte if FIN is set
size_t length_in_sequence_space() const;
};
这个类就比较明确,这是一个完整的TCP报文,包括TCP头字段header和负载Buffer。
TCPHeader结构体
我们先查看头字段TCPHeader的结构体定义:
cpp
struct TCPHeader {
static constexpr size_t LENGTH = 20; //!< [TCP](\ref rfc::rfc793) header length, not including options
//! \struct TCPHeader
//! ~~~{.txt}
//! 0 1 2 3
//! 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! | Source Port | Destination Port |
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! | Sequence Number |
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! | Acknowledgment Number |
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! | Data | |U|A|P|R|S|F| |
//! | Offset| Reserved |R|C|S|S|Y|I| Window |
//! | | |G|K|H|T|N|N| |
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! | Checksum | Urgent Pointer |
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! | Options | Padding |
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! | data |
//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//! ~~~
//! \name TCP Header fields
//!@{
uint16_t sport = 0; //!< source port
uint16_t dport = 0; //!< destination port
WrappingInt32 seqno{0}; //!< sequence number
WrappingInt32 ackno{0}; //!< ack number
uint8_t doff = LENGTH / 4; //!< data offset
bool urg = false; //!< urgent flag
bool ack = false; //!< ack flag
bool psh = false; //!< push flag
bool rst = false; //!< rst flag
bool syn = false; //!< syn flag
bool fin = false; //!< fin flag
uint16_t win = 0; //!< window size
uint16_t cksum = 0; //!< checksum
uint16_t uptr = 0; //!< urgent pointer
//!@}
//! Parse the TCP fields from the provided NetParser
ParseResult parse(NetParser &p);
//! Serialize the TCP fields
std::string serialize() const;
//! Return a string containing a header in human-readable format
std::string to_string() const;
//! Return a string containing a human-readable summary of the header
std::string summary() const;
bool operator==(const TCPHeader &other) const;
};
从以上可以看出,这就是TCP头的格式,而且该数据结构为struct可以直接获取其中的数据信息。
Buffer类定义
以下为负载Buffer的类定义:
cpp
//! \brief A reference-counted read-only string that can discard bytes from the front
class Buffer {
private:
std::shared_ptr<std::string> _storage{};
size_t _starting_offset{};
public:
Buffer() = default;
//! \brief Construct by taking ownership of a string
Buffer(std::string &&str) noexcept : _storage(std::make_shared<std::string>(std::move(str))) {}
//! \name Expose contents as a std::string_view
//!@{
std::string_view str() const {
if (not _storage) {
return {};
}
return {_storage->data() + _starting_offset, _storage->size() - _starting_offset};
}
operator std::string_view() const { return str(); }
//!@}
//! \brief Get character at location `n`
uint8_t at(const size_t n) const { return str().at(n); }
//! \brief Size of the string
size_t size() const { return str().size(); }
//! \brief Make a copy to a new std::string
std::string copy() const { return std::string(str()); }
//! \brief Discard the first `n` bytes of the string (does not require a copy or move)
//! \note Doesn't free any memory until the whole string has been discarded in all copies of the Buffer.
void remove_prefix(const size_t n);
};
Buffer类的定义略微复杂,私有成员变量就两个,分别是std::shared_ptr<std::string> _storage智能指针,指向是寄存处字符串数据的内存区域。和size_t _starting_offset表示当前数据的"起始位置",作用是当想要丢弃前五个字节时,系统并不真的删除内存,而是直接在这个偏移量+5.
接下来分析一下中重要的成员方法:
- 构造函数包括一个默认构造和一个形参为字符串地址的构造,第二个构造函数比较重要,它接管了字符串的所有权,这是Buffer的主要数据接入口。
- str()方法可以返回当前Buffer剩余有效数据的"视图"。at()方法可以返回第n个字节。size()返回当前有效数据长度。copy()将有效的数据深拷贝出来,生成一个新的std::string。
- 最后是丢弃数据的remove_prefix方法,仅offsert+=n,不实际发生丢弃操作。
可以看出我们需要的是copy()函数就可以拿到该负载代表的字节流的字符串。
segment_received函数实现
之后我们就可以实现segment_received函数:
- 根据提示,这个函数需要完成两个任务,设立ISN和推送数据到排序器。
- 首先考虑ISN的保存,我这里采用了optional容器存储ISN,当syn报文第一次过来时,存储其seq,当已经有isn但syn还过来后,我这里判断是否和之前相同,否则直接丢弃。
- 数据推送部分就是将TCP数据报中的数据负载拿出来,推送到排序器中。这里需要取出TCP头中的FIN字段,确定这是不是字节流的末尾,并将相关信息同时告知排序器。
以上是正常要完成的操作,但这里存在一些坑点:
1)首先明晰收到FIN数据报后不是不再接收数据报了(因为可能比他小的字节延迟到达),而是不再接收超过规定的数据报了。换句话说,一旦收到FIN数据包,排序器中的最后字节就定死了,超出这个字节的数据报就不再接收了。所以这里真正的不接受的情况是排序器结束(排序器无未排序的字节且目前处理到的字节序号=标记的最后字节号)。所以这里也需要修改排序器的逻辑,排序器修改如下,重点是窗口这里和last_index取min。
cpp
void StreamReassembler::push_substring(const string &data, const size_t index, const bool eof) {
//已经收到结束的字节信号并且字节序已经写到结束信号了,就不允许继续插入了。
if(_eof && _index == _last_index) return;
if(eof)
{
_eof = eof;
_last_index = index + data.length(); //这里记录最后的字节
}
//首先尽可能的将字节存入map中,首先计算能接受的窗口区间
size_t window_l = _index;
size_t window_r = max(_index + _capacity - _output.buffer_size(), _last_index); //这里收到结束信号以后,就不再接收last_index以外的字节了
for(size_t i = 0; i < data.length(); i++) {
if(index + i < window_l) continue;
if(index + i >= window_r) break;
_buffer[index + i] = data[i];
}
//由于有新插入的,所以可以更新_buffer到有序的字节流中
while(_buffer.find(_index) != _buffer.end()) {
std::string st(1, _buffer[_index]);
_buffer.erase(_index);
_index += _output.write(st);
}
//如果收到结束信号且已经写完了,关闭ByteStream
if(_eof && _index == _last_index) {
_output.end_input();
}
}
同时这里增加判断排序器是否结束的函数:
cpp
//返回当前排序器是否已经收到结束信号并且已经排序结束。
bool end() const {return _eof & empty();}
这样我们就可以在segment_received开头加上结束直接返回的代码了:
cpp
//如果排序器结束了就直接返回。坑点!!!!
if(_reassembler.end()) return;
2)unwrap的返回的是绝对序列号,我们还要将其转化成字节号bystream index,这里直接-1即可。但由于FIN占一个字节,若FIN报文段本身携带数据,导致其数据实际序列号实际从ISN+1开始,所以我们这里要进行判断当前是否是FIN报文,是的话,序列号+1。
最终segment_received代码如下所示:
cpp
void TCPReceiver::segment_received(const TCPSegment &seg) {
//如果排序器结束了就直接返回。坑点!!!!
if(_reassembler.end()) return;
const TCPHeader &header = seg.header();
//如果设置了syn,就在第一次见到syn设置包时设置他,如果不是第一次syn,检查是否相同,不同则返回。
if(header.syn) {
if(!_isn.has_value()) {
_isn = header.seqno;
}
else {
if(header.seqno != _isn.value())
return;
}
} //如果不是syn并且isn没设置,说明是非法数据报,直接丢弃。
else {
if(!_isn.has_value())
return;
}
//能来这里的只有设置了syn:1.第一次来设置。2.第二次来设置且isn和上次相同。或者没设置syn但isn早已有值。
//这里设置是否收到fin了
bool end = header.fin;
//这里向字符串重组器推送数据
const std::string payload = seg.payload().copy();
//这里为了防止syn数据包携带数据
WrappingInt32 seqno(header.seqno.raw_value());
if(header.syn) seqno = seqno + static_cast<uint32_t>(1);
//推送数据
_reassembler.push_substring(payload, _last_abseqno = unwrap(seqno, _isn.value(), _last_abseqno) - 1, end);
}
ackno函数实现
此外我们还需要实现ackno函数,ackno表示期望发送方的下一个序列号。这里我们需要得知排序器目前需要哪个字节号,所以实现一个get_index()函数返回其index。
cpp
//返回当前排序器所需的字节号
uint64_t get_index() const {return _index;}
这里返回的是字节号,所以需要转换成绝对序列号,需要+1,之后再转换成序列号,调用wrap函数即可。
这里同样需要注意FIN占用一个字节号,所以考虑若当前排序器结束,将绝对序列号+1.(注意这里判断的是排序器结束而不是收到FIN数据报,原因是若FIN数据报收到,但前放某个字节没到,其ackno还是那个字节号并不收到FIN数据报的影响,所以仅当index刚好处理完FIN数据报的前一个字节时才需要ackno+1,可考虑FIN数据报就是字节流的末尾,刚好处理完FIN数据报的前一个字节,再加上FIN数据报,那么排序器应该处于结束状态,所以这里直接判断排序器是否处于结束状态即可。)
最终代码如下:
cpp
optional<WrappingInt32> TCPReceiver::ackno() const {
optional<WrappingInt32> ackno{std::nullopt};
//这里的坑点,如果已经接收到FIN报文并且排序结束了,则我们的绝对序列号和实际序列号seqno实际上错开了一位这里补上,
//并且这里FIN表示连接结束,所以不会再收到数据包,后续没有序列号之间的转换了,这里单纯加一不影响
uint64_t ab_seqno = _reassembler.get_index() + 1;
if(_reassembler.end()) ab_seqno ++;
if(_isn.has_value()) //这里注意get_index返回的是字节流号,先+1转换成绝对字节流号,再wrap转换成seqno
ackno = wrap(ab_seqno, _isn.value());
return ackno;
}
window_size()函数实现
这个应该算是三个函数中最简单的一个了,这个window_size实际上就是排序器中能容忍的未排序的字节个数,也就是字节流中的剩余空字节个数,这里直接在排序器中添加函数即可:
cpp
//返回当前未排序的窗口大小
size_t get_window() const {return _output.remaining_capacity();}
之后在window_size()中直接调用即可:
cpp
//这里返回排序器的窗口大小即允许容纳未排序的字节个数。
size_t TCPReceiver::window_size() const {
return _reassembler.get_window();
}
这样本次Lab2实验就结束了,成功截图如下,完结撒花!!!

C++知识积累
先鸽掉,待补。