CS144 Lab:Lab2

目录

Wrap

wrap函数

unwrap函数

[TCP Receiver](#TCP Receiver)

TCPSegment类

TCPHeader结构体

Buffer类定义

segment_received函数实现

ackno函数实现

window_size()函数实现

C++知识积累

[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.接下来我们只需要比较这三个哪个才是答案即可。

这里有几个坑点:

  1. 注意这里n - isn返回类型是int32,所以可能出现负数,而负数强转成uint64时会变成一个很大的数,所以这里要先将n-isn强转成uint32,再强转成uint64.
  2. 注意这里判断哪个更近时,不能直接简单candidate加减2^32,因为这可能导致越界回环。这里采取的措施判断candidate和checkpoint的距离和2^32/2之间的关系来判断取前面还是后面。
  3. 注意即使另一个候选者更近,但如果他加减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.

接下来分析一下中重要的成员方法:

  1. 构造函数包括一个默认构造和一个形参为字符串地址的构造,第二个构造函数比较重要,它接管了字符串的所有权,这是Buffer的主要数据接入口。
  2. str()方法可以返回当前Buffer剩余有效数据的"视图"。at()方法可以返回第n个字节。size()返回当前有效数据长度。copy()将有效的数据深拷贝出来,生成一个新的std::string。
  3. 最后是丢弃数据的remove_prefix方法,仅offsert+=n,不实际发生丢弃操作。

可以看出我们需要的是copy()函数就可以拿到该负载代表的字节流的字符串。

segment_received函数实现

之后我们就可以实现segment_received函数:

  1. 根据提示,这个函数需要完成两个任务,设立ISN和推送数据到排序器。
  2. 首先考虑ISN的保存,我这里采用了optional容器存储ISN,当syn报文第一次过来时,存储其seq,当已经有isn但syn还过来后,我这里判断是否和之前相同,否则直接丢弃。
  3. 数据推送部分就是将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++知识积累

先鸽掉,待补。

Optional 容器

智能指针

右值引用

相关推荐
gcfer1 个月前
CS144 学习导航
cs144
Rinai_R6 个月前
CS144 - LAB0
c语言·windows·计算机网络·cpp·计算机基础·cs144