CS144 Lab【0~4】

CS144

这个实验主要是用来搭一个自己的TCP协议栈,通过Lab0使用Linux封装好的的socket使用感受计算机网络的基础通信,然后一步步实现一个自己的socket,来搭一个TCP协议栈。

Lab0

Lab0概述

Lab0主要是通过对一些封装好的命令的使用体验一下计算机网络通信的魅力,并实现TCP协议栈中的字节流部分。

fetch界面

这个我用手册中的命令没能实现,用curl命令替代了。
在这里插入图片描述

互联

在这里插入图片描述

利用Linux下自己的TCPsocket写一个fetch界面的程序

在这里插入图片描述主要就是通过GET命令来获取网页,在 HTTP 协议中,Host 头部用于指定请求的服务器的域名,而 Connection: close 则用于指示服务器在发送完响应后关闭 TCP连接。

这就是使用TCPsocket写的一个获取网页的程序,在接下来的实验中会一步一步实现一个自己的TCPsocket!

接下来是Lab0中最重要的一部分:

实现字节流

首先要了解字节流是干什么的,我们要实现一个字节流用来把数据从下层传递给上层,也就是说这个字节流要有读写功能,下层来写数据,上层来读取数据。

代码:

  #include "byte_stream.hh"

using namespace std;

ByteStream::ByteStream(uint64_t capacity)
    : capacity_(capacity), end_write(false), end_read(false), write_size(0), read_size(0), buffer() {}

bool Writer::is_closed() const {
    return end_write;
}

void Writer::push(string data) {
    if (available_capacity() == 0)
        return;
    size_t num = min(data.size(), available_capacity());
    buffer.insert(buffer.end(), make_move_iterator(data.begin()), make_move_iterator(data.begin() + num));
    write_size += num;
}

void Writer::close() {
    end_write = true;
}

uint64_t Writer::available_capacity() const {
    uint64_t num = capacity_ - buffer.size();
    return num;
}

uint64_t Writer::bytes_pushed() const {
    return write_size;
}

bool Reader::is_finished() const {
    return (end_write && buffer.empty()) ? true : false;
}

uint64_t Reader::bytes_popped() const {
    return read_size;
}

string Reader::peek() const {
    return std::string(buffer.begin(), buffer.end());
}

void Reader::pop(uint64_t len) {
    if (len > buffer.size()) {
        set_error();
        return;
    }
    uint64_t tmp = len;
    while (tmp--) {
        buffer.pop_front();
    }
    read_size += len;
}

uint64_t Reader::bytes_buffered() const {
    return buffer.size();
}

在数据结构的选择上,由于需要支持读写操作,所以是需要在尾部插入,在头部读取,因此我用了deque,但是因为我这个版本的实验中Reader的peek函数给的是用string_view返回,在我的一次跑样例的时候出现了返回野指针的错误,通过看大佬的博客看到好像是因为 C++ 里的 deque 并不完全内存连续即在多次写入后会在新地区开新内存,又因为string_view只存地址和偏移导致 string_view 的内容有问题。当多次调用 deque的push 和 pop 后,deque 在新开内存会导致内存不连续,进而导致 string_view 内容会存在 \0x00 的溢出。
但我又没有找到好的解决方法,所以这里我偷偷把他的string_view改成string了。 我觉得正常他应该是想让你手写一个类似循环队列的东西,这里就偷个懒吧。
特别注意一下,C++构造函数初始化的顺序只和定义变量的顺序有关,所以要先初始化capacity_,再用capacity_来初始化buffer的大小。
然后为了优化性能减少复制,我又使用了move_iterator来优化,但我的性能和测试样例还是只能在网速够快的情况下通过,但我没能找到更好的办法,不知道是不是deque也会导致性能变慢,如果有大佬能解决可以教教我。

Lab1

Lab1概述

Lab1是让我们实现重组器,通过理论课我们知道了网络层的数据是不可靠的,那么TCP协议是怎么实现的"可靠"呢?通过这个实验我们可以很好的了解TCP怎么保证数据的“可靠”。

设计Lab1的思路

首先看一下手册上面给的信息:
在这里插入图片描述
手册中提到每一个byte都有一个标号index,并且index从0开始,这个从0开始也为我们函数里面计算图片中的first unpopped index,first unassembled index,first unacceptable index省去了许多+1的麻烦。
然后来分析怎么去写:
首先我们需要存储图片中的整个数据,也就是所有想要写进字节流的字符,因为每一个字符都有一个index标号,所以我们可以想到用map这种key-value的数据结构来一对一存储。
由于网络层可能涉及到乱序,重传等多种情况,所以我们应该考虑怎么将这些数据放进字节流,手册中也说明了,只有当某一个编号的字符前面所有的字符都知道了,这一串字符才会被写入字节流,也就是说加入我们得到了编号是3的字符,那么只有我知道了0123,才会将其写入,否则我们就先不写入字节流口,而是等待,等到我们知道了0123再传入。
因此,我们再看手册中的图片可知:
蓝色部分指的是:已经写进字节流的字符
绿色部分指的是写进了map结构(buf_)已经有序了,但还没向字节流里写的数据。
红色部分指的是已经在map里了但是他前面的字符还不知道因此不能传进字节流的数据。

主要的过程就是我们从上一层收到字符串data,传给我们写的这个map结构,这时map得到的需要是没有重复的数据,然后当这个标号的前面所有字符都知道了就将这一串字符写进字节流供给上层读取。
可以分情况讨论,比如:
在这里插入图片描述这种情况下在绿色部分的数据就不要,只把红色部分存进map即可。
而以下这种因为没有收到过,不涉及重复,全存即可:
在这里插入图片描述注意我们在Lab0实现的字节流是有容量限制的,我们不能超出这个容量限制。

代码:

#include "reassembler.hh"
#include <sstream>
using namespace std;

void Reassembler::insert(uint64_t first_index, string data, bool is_last_substring) {
    if (is_last_substring) {
        flag_eof = true;
        index_eof = first_index + data.size();
    }
    for (size_t i = 0; i < (size_t)data.size(); ++i) {
        if (first_index + i >= get_unsort() && first_index + i < get_unaccept()) {
            buf_[first_index + i] = data[i];
        }
    }
    string str = "";
    ostringstream str_stream;
    size_t now = get_unsort(); /* 已经插入的字符 */
    for (auto& to : buf_) {
        size_t id = to.first;
        char ch = to.second;
        if (id > now || now >= get_unaccept())
            break;
        if (id < now) {
            continue;
        }
        if (id == now) {
            str_stream << ch;
            ++now;
        }
    }
    str = str_stream.str();
    output_.writer().push(str);
    if (flag_eof && get_unsort() == index_eof)
        output_.writer().close();
}

uint64_t Reassembler::bytes_pending() const {
    return buf_.size() - output_.writer().bytes_pushed();
}

性能测试目前还是无法通过的,不知道是不是Lab0的问题。

Lab2

Lab2概述

这个实验主要是来实现手册中的三个序列号之间的转换,和接收端。做到这里想跑样例的时候发现前面的性能测试跑不过无法进行后面的验证了,但是在速度方面我也实在没能想到其他更快的办法~~(我觉得是我电脑的问题,他真的已经很快了)~~ 如果有大佬有改进意见可以提出哈OvO。为了能正常进行我用了很恶心的方法------改测试样例的速度限制。
1也是实在想不到办法为了正常进行才这样的。
对于序列的转换关键点就是这里的图片了:
在这里插入图片描述
之所以要转换实际上是因为:
在这里插入图片描述
在TCP的协议头中我们也能知道seq,ack都是32位循环的,但是我们在接收数据的时候我们是不能有重复的,因此需要一个绝对序列号,手册中也说到了,绝对序列号不会超过uint64_t,所以不用考虑超过uint64_t怎么办了。

序列转换的设计思路

由于seqno是一个32位类型的数字,在超过2^32-1后就会回到0,因此我们写两个函数,一个用来从absolute seqno->seqno,另一个用来seqno->absolute seqno。第一个比较好写转成32位让他自动取余就可以了(有点像字符串哈希的思路),第二个卡了好久,先看一下代码:

#include "wrapping_integers.hh"
#include <math.h>
using namespace std;

Wrap32 Wrap32::wrap(uint64_t n, Wrap32 zero_point) {
    return Wrap32(zero_point.raw_value_ + static_cast<uint32_t>(n));
}

uint64_t Wrap32::unwrap(Wrap32 zero_point, uint64_t checkpoint) const {
    constexpr uint64_t pow2_31 = (1UL << 31);
    constexpr uint64_t pow2_32 = (1UL << 32);
    uint64_t dis = raw_value_ - static_cast<uint32_t>(checkpoint + zero_point.raw_value_); //现在的32位序列号 与 绝对序列checkpoint转化到32位的序列号 的差值
    if (dis < pow2_31 || checkpoint + dis < pow2_32) 
        return checkpoint + dis;
    return checkpoint + dis - pow2_32;
}

代码很短 主要分为两种情况,下面是最简单的一种情况:
在这里插入图片描述看图片就很好理解了,这种情况的话(即checkpoint对应到seqno序列上的点在当前的raw_value_的左边一点)求出在32位上的偏移量dis,直接加上就可以了。
那么如果checkpoint对应在32位上的点在当前raw_value_的右边呢?
首先,我认为要理解第二种情况首先要理解这个测试结果:
在这里插入图片描述
首先为我们要理解原码反码补码的存储规则,理解为什么static_cast<uint32_t>(-1)变成了2^32-1,在理解了这个的基础上再来看下面的情况。
我们来看一下图片:
在这里插入图片描述
这种情况下,我们的dis如果正常按照代码中的差值计算方法应该是负数,但是我们选择了一个uint类型去接收,负数就会转化成:uint64_t的最大值再减去abs(dis),所以,当前的dis实际上是这个值,因此我们得到的是这样的:
在这里插入图片描述
可以看出对应在绝对序列上面的当前点应该在checkpoint的左边,刚好应该是差了2^32-我们得到的dis,这样也就减去了我们图中黄色的部分。

TCPReceiver的实现

经过了漫长的改数据范围 也是成功通过了哈哈哈,今天在看的其他人的代码的时候发现了前面的解决方案,我看到了有人Lab0用了deque<string>的结构,然后就可以正常使用string_view返回peek_out函数了,但是我还是觉得更多的是我自身虚拟机的问题,复制一遍也不至于差这么多吧
在理论基础上我们知道了TCP的协议头是这样的:
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/53a491bad2844b1aae4236b062e73f42.png

这三个也是我们这部分代码中比较关键的部分。

下面是实现方法:
这一个部分理清楚代码实现相对简单,主要流程就是用来接收下层发来的包,如果接收到的TCPMessage包里面含有RST标志,那么就说明有错误需要重传,SYN 和 FIN 标志的作用如下:
SYN标志:用于建立连接。当 TCP 连接的一端想要建立一个连接时,它会发送一个带有 SYN 标志的 TCP 数据包。这个数据包用来同步序列号,以便两个端点可以开始跟踪数据传输中的序列号。SYN 标志通常与 ACK 标志一起使用,以建立稳定的连接状态,ACK的值表示你这个包前面的我都收到了,你可以开始发ACK号的包了。
FIN 标志:用于终止连接。当 TCP 连接的一端完成发送所有数据,并想要关闭连接时,它会发送一个带有 FIN 标志的 TCP 数据包。这表明发送端已经完成了数据发送,并准备关闭连接。接收端在收到 FIN 标志的数据包后,知道发送端已经没有更多的数据要发送,并将开始关闭连接的过程。
然后为了防止可能zero_point本来就会出现0值,无法将它初始化成0,所以选用了一个optional来记录,nullopt就表示optional没有值它可以明确表示某个类型是否有值(has_value()函数),特别注意一下,我们要发给重组器的编号是不包含SYN的,重组器只需要将要发送的数据排列好即可,不需要将标志也传进去,也就是在上一个序列转换手册中的这个部分:
在这里插入图片描述
然后send函数就比较简单了,
在这里插入图片描述
+1就是表示你前面的我都收到了,该发这个值了,也就是说ack应该是下一个没收到的序列号。

代码

#include "tcp_receiver.hh"

using namespace std;

void TCPReceiver::receive(TCPSenderMessage message) {
    if (message.RST) {
        reader().set_error();
        return;
    }
    if (message.SYN) zero_point = message.seqno;
    if (!zero_point.has_value()) return;
    uint64_t abs_seq = message.seqno.unwrap(zero_point.value(), writer().bytes_pushed());
    reassembler_.insert((abs_seq - (message.SYN == 0)), message.payload, message.FIN);
}

TCPReceiverMessage TCPReceiver::send() const {
    TCPReceiverMessage message;
    if (reassembler().reader().has_error()) {
        message.ackno = nullopt;
        message.window_size = 0;
        message.RST = true;
        return message;
    }
    message.RST = false;
    if (zero_point.has_value())
        message.ackno = Wrap32::wrap(writer().bytes_pushed() + writer().is_closed() + 1, zero_point.value());
    message.window_size = (writer().available_capacity() > UINT16_MAX) ? UINT16_MAX : writer().available_capacity();
    return message;
}

特别注意一下,我开始将optional<Wrap32>zero_point在构造函数里面初始化了也就是:
在这里插入图片描述
,就会一直报错,原因是:
在这里插入图片描述
给的send()函数是const,属于常量成员函数,在这个函数里面不能修改任何成员变量,所以不能在构造函数将其初始化。

结果

在这里插入图片描述
自然也是改了测试点数据范围之后的啦

Lab3

Lab3概述

Lab3是让我们实现TCP的Sender部分,是要我们完成TCP中的将数据发送给对方,并且受到对方的ack,改变窗口大小来进行拥塞控制的过程,在完成这一部分的实现前,我们一定要清楚,TCP协议连接的是对等的实体,任何一方都既可以是接收方,也可以是发送方。
我们要在这一部分主要实现三个函数:push,receive和tick。

push要求:

void push(const TransmitFunction& transmit):
TCP发送器被要求从传出字节流填充窗口:它从流中读取数据,并尽可能多地发送TCP发送器消息,只要还有新的字节可读且窗口中有可用空间。它通过调用提供的 transmit() 函数来发送它们。
你需要确保你发送的每个TCP发送器消息完全适合接收方的窗口。使每个单独的消息尽可能大,但不要超过 TCPConfig::MAX_PAYLOAD_SIZE(1452字节)的值。
你可以使用 TCPSenderMessage::sequence length() 方法来计算一个段所占用的序列号总数。记住,SYN和FIN标志也各占用一个序列号,这意味着它们在窗口中占用空间。
如果窗口大小为零,我应该怎么做?如果接收方宣布窗口大小为零,push 方法应该假装窗口大小为一。发送方可能会发送一个最终被接收方拒绝(并且未确认)的单个字节,但这也可以促使接收方发送一个新的确认段,在其中它透露其窗口中有更多的空间已经打开。没有这个,发送方将永远不会知道它被允许开始发送。
这是你的实现应该对零大小窗口情况有的唯一特殊行为。TCP发送器实际上不应该记住一个假的窗口大小为1。特殊情况只在 push 方法中。
特别注意一下,push函数的要求是填充窗口,是uint64_t类型,但是手册中还有要求:使每个单独的消息尽可能大,但不要超过 TCPConfig::MAX_PAYLOAD_SIZE(1452字节)的值,这意味着 每次调用push是发送填满窗口的包,而不是只发送一个包!!! 这里因为没搞清楚一直卡在一个点上。这里如果写过socket编程的话也会知道由于MSS的限制,我们会采用循环接收一个包的方式去处理,写的时候没有想到这里。
并且一定要记住:如果窗口大小为零,我应该怎么做?如果接收方宣布窗口大小为零,push 方法应该假装窗口大小为一。

receive要求

void receive(const TCPReceiverMessage& msg);
从接收方收到一条消息,这条消息传达了窗口的新左边界(即确认号 ackno)和右边界(即确认号 ackno 加上窗口大小)。TCP发送器应该查看其未确认段的集合,并移除任何现在已经完全确认的段(即确认号大于该段中的所有序列号)。

tick要求

void tick( uint64 t ms since last tick, const TransmitFunction& transmit );时间已经过去了——自上次调用这个方法以来已经过去了一定数量的毫秒。 发送方可能需要重传一个未确认的段;它可以通过调用 transmit() 函数来做到这一点。(提醒:请不要尝试在你的代码中使用现实世界的“时钟”函数;时间流逝的唯一参考来自自上次滴答以来的毫秒数参数。)

读完文档的要求时可以边读边整理一下我们要做的事情:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
这些是我们读完文档就能够整理出来的,知道了这些实现也会更加清晰。

数据结构的选择和成员变量的添加

首先整理完文档的内容很容易知道我们需要一个数据结构来维护所有outstanding的数据,容易想到因为收到ack后我们要根据序列号更新数据结构,因此我用了优先队列,写了一个伪函数来按照seqno排序:
在这里插入图片描述
在这里插入图片描述
这样就可以在收到ack后快速弹出已经不再outstanding的Message。
注意这里我的成员变量abs_ack_不是发送一个包的ack而是整个push发送完的ack。

代码

#pragma once

#include "byte_stream.hh"
#include "tcp_receiver_message.hh"
#include "tcp_sender_message.hh"

#include <cstdint>
#include <functional>
#include <list>
#include <memory>
#include <optional>
#include <queue>
#include <vector>

class TCPSender
{
public:
  /* Construct TCP sender with given default Retransmission Timeout and possible ISN */
  TCPSender( ByteStream&& input, Wrap32 isn, uint64_t initial_RTO_ms )
    : input_( std::move( input ) ), isn_( isn ), initial_RTO_ms_( initial_RTO_ms )
  {}

  /* Generate an empty TCPSenderMessage */
  TCPSenderMessage make_empty_message() const;

  /* Receive and process a TCPReceiverMessage from the peer's receiver */
  void receive( const TCPReceiverMessage& msg );

  /* Type of the `transmit` function that the push and tick methods can use to send messages */
  using TransmitFunction = std::function<void( const TCPSenderMessage& )>;

  /* Push bytes from the outbound stream */
  void push( const TransmitFunction& transmit );

  /* Time has passed by the given # of milliseconds since the last time the tick() method was called */
  void tick( uint64_t ms_since_last_tick, const TransmitFunction& transmit );

  // Accessors
  uint64_t sequence_numbers_in_flight() const;  // How many sequence numbers are outstanding?
  uint64_t consecutive_retransmissions() const; // How many consecutive *re*transmissions have happened?
  Writer& writer() { return input_.writer(); }
  const Writer& writer() const { return input_.writer(); }

  // Access input stream reader, but const-only (can't read from outside)
  const Reader& reader() const { return input_.reader(); }
  struct my_cmp
  {
    bool operator()( TCPSenderMessage a, TCPSenderMessage b ) { return a.seqno > b.seqno; }
  };

private:
  // Variables initialized in constructor
  ByteStream input_;
  Wrap32 isn_;
  uint64_t initial_RTO_ms_;
  uint64_t RTO_ms_{initial_RTO_ms_};                  // 现在的RTO,在初始化列表初始化了
  uint64_t abs_ack_ { 0 };                    // ack的绝对序列号(如果把push发的当成一个大包的话,这个是大包的ack而不是小包的)
  uint64_t abs_old_ack_ { 0 };                // 上一个ack用来当checkpoint
  uint64_t sequence_numbers_in_flight_ { 0 }; // 发送了但还没收到ack的字符数(outstanding的字符数量)
  uint64_t consecutive_retransmissions_ { 0 };
  uint64_t time_ms_ { 0 }; // 计时器时间
  uint16_t window_size_ { 1 };
  std::priority_queue<TCPSenderMessage, std::vector<TCPSenderMessage>, my_cmp> msg_queue_ {};
  bool FIN_ { false };
};
#include "tcp_sender.hh"
#include "tcp_config.hh"

using namespace std;

uint64_t TCPSender::sequence_numbers_in_flight() const
{
	// Your code here.
	return sequence_numbers_in_flight_;
}

uint64_t TCPSender::consecutive_retransmissions() const
{
	// Your code here.
	return consecutive_retransmissions_;
}

void TCPSender::push( const TransmitFunction& transmit )
{
	// Your code here.
	// 如果窗口已经比当前缓存的数据还小了,,就先不发送了(滑动窗口)
	if (sequence_numbers_in_flight_ >= (window_size_!=0?window_size_:1) ) {
		return;
	}
	Wrap32 seq_=Wrap32::wrap(abs_ack_,isn_);
	//计算当前还能接受的大小
	uint16_t
		accept_num_=(window_size_==0?1:window_size_-sequence_numbers_in_flight_-static_cast<uint16_t>(seq_==isn_));
	//得到能够传输的字符串
	string s="";
	if(reader().bytes_buffered()) {
		s=reader().peek();
		s=s.substr(0,accept_num_);
		input_.reader().pop(s.size());
	}
	size_t len=0;
	string_view view(s);
	while(view.size()||seq_==isn_||(!FIN_&&writer().is_closed())) {
		len=min(view.size(),TCPConfig::MAX_PAYLOAD_SIZE);
		string payload( view.substr( 0, len ) );
		//这里手册中提到不能有FIN直接就发,要看情况 可能留到下次发FIN
		TCPSenderMessage message {seq_,seq_==isn_,payload,false,writer().has_error()};
		//看看FIN够不够发
		if(!FIN_&&writer().is_closed()&&len==view.size()&&(sequence_numbers_in_flight_ + message.sequence_length()<(window_size_==0?1:window_size_) )) { FIN_=message.FIN=true;
		}
		transmit(message);
		abs_ack_+=message.sequence_length();
		sequence_numbers_in_flight_+=message.sequence_length();
		msg_queue_.push(move(message));
		if(!FIN_&&writer().is_closed()&&len==view.size()) break;
		seq_=Wrap32::wrap(abs_ack_,isn_);
		view.remove_prefix(len);
	}
}

TCPSenderMessage TCPSender::make_empty_message() const
{
	// Your code here.
	return { Wrap32::wrap( abs_ack_, isn_ ), false, "", false, writer().has_error() };
}

void TCPSender::receive( const TCPReceiverMessage& msg )
{
	// Your code here.
	if ( msg.RST ) {
		writer().set_error();
		return;
	}
	window_size_ = msg.window_size;

	uint64_t abs_recv_ack_ = msg.ackno ? msg.ackno.value().unwrap( isn_, abs_old_ack_ ) : 0;
	if ( abs_recv_ack_ > abs_old_ack_ && abs_recv_ack_ <= abs_ack_ ) {
		abs_old_ack_ = abs_recv_ack_;
		// 计时器清0
		time_ms_ = 0;
		//RTO设置为初始值
		RTO_ms_ = initial_RTO_ms_;
		//连续重传次数清零
		consecutive_retransmissions_ = 0;

		uint64_t abs_seq = msg_queue_.top().seqno.unwrap( isn_, abs_old_ack_ ) +     msg_queue_.top().sequence_length();
		while ( !msg_queue_.empty() && abs_seq <= abs_recv_ack_ ) {
			if ( abs_seq <= abs_recv_ack_ ) {
				sequence_numbers_in_flight_ -= msg_queue_.top().sequence_length();
				msg_queue_.pop();
				abs_seq = msg_queue_.top().seqno.unwrap( isn_, abs_old_ack_ ) +     msg_queue_.top().sequence_length();
			}
		}
	}
}

void TCPSender::tick( uint64_t ms_since_last_tick, const TransmitFunction& transmit )
{
	// Your code here.
	if ( !msg_queue_.empty() ) {
		time_ms_ += ms_since_last_tick;
	}
	// 如果在重传之前收到ack并且队列为空,则不需要重传了,此时timer相关信息已经清0
	if ( time_ms_ >= RTO_ms_ ) {//等待时间大于RTO,重传
		transmit( msg_queue_.top() );
		if ( window_size_ > 0 ) {//注意window_size_=0不触发RTO避退
			++consecutive_retransmissions_;
			RTO_ms_ <<= 1;
		}
		time_ms_ = 0;
	}
}

结果

在这里插入图片描述

总结

这一部分的实现一定要读清楚文档中的内容,就比如前面提到的push函数发送多少数据的问题,一定要搞清楚,以及我们所设计的成员变量应当在哪一部分正确的更新,要提前思考清楚。
同时在写这个Lab的时候也可以思考一些其他的知识,比如我在这个Lab的实现中也更加清楚了SYN_Flood泛洪攻击时为什么半连接队列默认是1024时,每秒发送200个伪造的SYN包就足够撑爆半连接队列,因为重传时RTO会不断地×2,以至于默认重传5次的话,一个伪造的SYN报文就会占用1+2+4+8+16+32(最后一次还要等32s才知道最后也超时了),因此一个伪造的SYN包就会占用63s之久。

Lab4

Lab4概述

Lab4就只是验证我们之前写的各个部分能不能成功的在网络中进行通信,如果有问题进行调试就好。
成功后我们就可以使用我们自己完成的这个socket在网络中的通信啦!

结果

在这里插入图片描述

可以ping一下其他网站试试看:
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值