TCP与UDP协议与应用

TCP

TCP段结构

传输控制协议接受来自数据流的数据,将其分成块,并添加创建TCP段的TCP头。然后将 TCP段封装到 Internet 协议 (IP) 数据报中,并与对等方进行交换。

术语 TCP 数据包出现在非正式和正式用法中,而在更精确的术语中,段指的是TCP协议数据单元 (PDU),数据报指的是 IP PDU,帧指的是数据链路层 PDU:

进程通过调用 TCP 并将数据缓冲区作为参数传递来传输数据。 TCP 将这些缓冲区中的数据打包成段并调用 Internet 模块,将每个段传输到目标 TCP。

TCP段由段头和数据段组成。段头包含10个必要字段和一个可选的扩展字段(可选项)。数据部分跟在段头后面,是为应用程序携带的有效载荷数据。数据段的长度没有在段头中指定;它可以通过从IP报头中指定的IP数据报总长度中减去段报头和IP报头的长度来计算。
在这里插入图片描述

  • 源端口(16 位),标识发送端口。
  • 目标端口(16 位),标识接收端口。
  • 序列号(32 位),具有双重作用:如果设置了 SYN 标志 (1),则这是初始序列号。实际第一个数据字节的序列号和相应ACK中的确认号就是这个序列号加1。如果 SYN 标志清除 (0),则这是当前会话的该段的第一个数据字节的累积序列号。
  • 确认号(32 位),如果设置了 ACK 标志,则该字段的值是 ACK 的发送者期望的下一个序列号。这确认收到所有先前的字节(如果有)。每一端发送的第一个 ACK​​ 确认另一端的初始序列号本身,但没有数据。
  • 数据偏移(4 位)以 32 位字指定TCP段头的大小。段头最小为 5 个字,最大为 15 个字,因此最小为 20 个字节,最大为 60 个字节,段头最多允许 40 个字节的选项。这个字段的名字来源于它也是从 TCP 段的开始到实际数据的偏移量。
  • 保留(3 位),为将来使用,应设置为零。
  • 标志(9 位),包含 9 个 1 位标志(控制位)
位名称说明
NS (1 bit)ECN-nonce - 隐藏保护
CWR(1 位)拥塞窗口减少 (CWR) 标志由发送主机设置,以指示它收到了一个设置了 ECE 标志的 TCP 段并在拥塞控制机制中做出了响应。
ECE(1 位)ECN-Echo 有双重作用,取决于 SYN 标志的值。如果设置了 SYN 标志 (1),则 TCP 对等方具有 ECN 能力。如果 SYN 标志清除 (0),则表示在正常传输期间接收到 IP 报头中设置了拥塞经历标志 (ECN=11) 的数据包。这用作网络拥塞(或即将发生拥塞)的指示以TCP 发送方。
URG(1 位)表示 Urgent 指针字段有效
ACK(1 位)表示确认字段是重要的。客户端发送的初始 SYN 数据包之后的所有数据包都应设置此标志。
PSH(1 位)推送功能。请求将缓冲的数据推送到接收应用程序。
RST(1 位)重置连接
SYN(1 位)同步序列号。只有从每一端发送的第一个数据包才应该设置这个标志。其他一些标志和字段会根据此标志更改含义,有些仅在设置时才有效,有些则在清除时才有效。
FIN(1 位)来自发送方的最后一个数据包
  • 窗口大小(16 位), 接收窗口的大小,指定该段的发送方当前愿意接收的窗口大小单位的数量。
  • 校验和(16 位), 16 位校验和字段用于TCP报头、有效载荷和IP伪报头的错误检查。伪头由源 IP 地址、目标IP地址、TCP协议的协议号 (6) 以及 TCP 头和有效载荷的长度(以字节为单位)组成。
  • 紧急指针(16位), 如果设置了 URG 标志,则该 16 位字段是与指示最后一个紧急数据字节的序列号的偏移量。
  • 可选项(可变 0-320 位,以 32 位为单位),该字段的长度由数据偏移字段决定。选项最多有三个字段:Option-Kind(1 字节)、Option-Length(1 字节)、Option-Data(变量)。 Option-Kind 字段指示选项的类型,并且是唯一非可选的字段。根据 Option-Kind 值,可以设置接下来的两个字段。 Option-Length 表示选项的总长度,Option-Data 包含与选项相关的数据(如果适用)。例如,Option-Kind 字节为 1 表示这是一个仅用于填充的无操作选项,并且后面没有 Option-Length 或 Option-Data 字段。一个 Option-Kind 字节为 0 标志着选项的结束,也只有一个字节。 Option-Kind 字节为 2 用于指示最大段大小选项,后面跟着一个 Option-Length 字节,指定 MSS 字段的长度。 Option-Length 是给定选项字段的总长度,包括 Option-Kind 和 Option-Length 字段。因此,虽然 MSS 值通常用两个字节表示,但 Option-Length 将为 4。例如,值为 0x05B4 的 MSS 选项字段在 TCP 选项部分编码为 (0x02 0x04 0x05B4)。
    某些选项可能只有在设置了 SYN 时才会发送;它们在下面表示为 [SYN]。 Option-Kind 和标准长度表示为 (Option-Kind, Option-Length)。

链接建立过程

TCP使用三路握手过程建立连接。连接的每个方向上的数据流都是独立控制的,以避免与初始序列号产生歧义。这些反过来被确认为握手过程的一部分。下图显示了这种三向握手的建立。

TCP 连接建立

  1. 发起方(客户端)发送一个段,其中设置了 SYN 标志,并在序列号字段 (SEQ = X) 中设置初始序列号。
  2. 响应方(服务器端)收到此消息后,记录传入方向的序列号,然后返回一个段,其中设置了 SYN 和 ACK 标志,其中序列号字段,设置为其自己为反向分配的值 (SEQ = Y),以及 X + 1 (PACK = X + 1) 的捎带式确认字段,以确认它已注意到其传入方向的初始值。
  3. 发起方收到此消息后,返回一个段,其中设置了ACK 标志和序列号Y + 1。

三路握手建立链接

TCP用于建立和终止连接的算法称为三路握手。我们首先描述基本算法,然后展示它是如何被 TCP 使用的。三路握手涉及在客户端和服务器之间交换三个消息,见下图。

三路握手算法的时间线
这个想法是,双方想要就一组参数达成一致,在打开TCP连接的情况下,这些参数是双方计划用于各自字节流的起始序列号。通常,参数可以是每一方希望​​另一方知道的任何事实。

  1. 首先,客户端(主动参与者)向服务器(被动参与者)发送一个段,说明它计划使用的初始序列号(Flags = SYN,SequenceNum = x)。
  2. 服务器用一个片段进行响应,该片段既确认客户端的序列号(Flags = ACK,Ack = x + 1)并声明它自己的起始序列号(Flags = SYN,SequenceNum = y)。也就是说,SYN 和 ACK 位都设置在该第二个消息的标志字段中。
  3. 客户端用第三段响应服务器的序列号(Flags = ACK,Ack = y + 1)。

每一方确认的序列号比发送的序列号大一,其原因是,确认字段实际上标识了“预期的下一个序列号”,从而隐含地确认所有较早的序列号。尽管未在此时间线中显示,但会为前两个段中的每一个安排一个计时器,如果未收到预期响应,则重新传输该段。

为什么客户端和服务器必须在连接建立时,相互交换起始序列号。如果每一端只是从某个“众所周知”的序列号开始,比如 0,那会更简单。事实上,TCP规范要求连接的每一端随机选择一个初始的起始序列号。这样做的原因是,为了防止同一连接的两个化身过早地重用相同的序列号。

三路握手打开和关闭连接

下图显示了一个基本的三向握手。步骤是:
在这里插入图片描述

  1. 发起者的初始状态是 CLOSED,而接收者的初始状态是 LISTEN。
  2. 发起方进入 SYN-SENT 状态,发送一个设置了 SYN 位的数据包,其起始序列号为 999(当前序列号,因此下一个发送的数字为 1000)。当接收方收到此消息时,进入 SYN-RECEIVED 状态。
  3. 接收者发回一个设置了 SYN 和 ACK 位的TCP数据包,这表明它是一个 SYN 数据包,并且它正在确认前一个 SYN 数据包。在这种情况下,接收者告诉发起者它将从 100 的序列号开始传输。确认号是 1000,这是接收者希望接下来接收的序列号。发起方收到此消息后,进入 ESTABLISHED 状态。
  4. 发起方发回一个TCP数据包,其中设置了ACK 位,确认号为 101,这是它希望接下来看到的序列号。
  5. 始发者发送序列号为 1000 的数据。

三路握手防止重复连接

下图显示了三路握手如何防止旧的重复连接启动造成混淆。在状态 3 中,收到了来自先前连接的重复 SYN。接收者发回对此的确认 (4),但是,当发起者收到此确认时,发起者发回 RST重置数据包。这会导致接收者返回到 LISTEN 状态。然后,它会收到2中发送的SYN数据包,并在确认之后,建立连接。
在这里插入图片描述
如果其中一个 TCP 已关闭或中止,而另一端仍处于连接状态,则 TCP 连接将处于半开状态。如果两个连接由于系统崩溃,而变得不同步,半开状态也可能发生。如果数据以任一方向发送,此连接将自动重置。这是因为序列号不正确,否则连接将超时。

三路握手关闭连接

通常使用 CLOSE 调用关闭连接。关闭的主机不能继续发送,但可以继续接收,直到对方告诉它关闭。下图显示了关闭连接的典型顺序。通常,应用程序为给定的连接发送一个 CLOSE 调用。接下来,发送一个设置了 FIN 位的 TCP 数据包,发起方进入 FIN-WAIT-1 状态。当另一个 TCP 已经确认了FIN, 并发送了自己的 FIN 时,第一个TCP可以确认这个 FIN。
在这里插入图片描述

应用程序设计

服务器端

套接字(socket)创建:
int sockfd = socket(domain, type, protocol)

sockfd:套接字描述符,一个整数
domain:整数,通信域,例如 AF_INET(IPv4 协议)、AF_INET6(IPv6 协议)
type:通讯型

  • SOCK_STREAM:TCP(可靠,面向连接)
  • SOCK_DGRAM:UDP(不可靠,无连接)

protocol:Internet 协议 (IP) 的协议值,为 0。这与出现在数据包 IP 标头中的协议字段上的数字相同。

设置选项:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

这有助于操作由文件描述符 sockfd 引用的套接字的选项。这是完全可选的,但它有助于重用地址和端口。防止诸如“地址已在使用中”之类的错误。

绑定:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

创建套接字后,bind 函数将套接字绑定到 addr(自定义数据结构)中指定的地址和端口号。在示例代码中,我们将服务器绑定到本地主机,因此我们使用 INADDR_ANY 来指定 IP 地址。

监听:
int listen(int sockfd,int backlog);

它将服务器套接字置于被动模式,等待客户端接近服务器以建立连接。积压,定义了 sockfd 的挂起连接队列可以增长到的最大长度。如果连接请求在队列已满时到达,客户端可能会收到带有 ECONNREFUSED 指示的错误。

接受客户端的连接请求:
int new_socket= accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

它提取侦听套接字 sockfd 的挂起连接队列中的第一个连接请求,创建新的连接套接字,并返回引用该套接字的新文件描述符。此时,客户端和服务器之间建立连接,准备传输数据。

客户端

套接字创建:
与服务器端创建套接字完全相同
连接:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect() 系统调用将文件描述符 sockfd 引用的套接字连接到 addr 指定的地址。服务器的地址和端口在 addr 中指定。

setsockopt与套接字行为控制

int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

该函数应在 level 参数指定的协议级别,将 option_name 参数指定的选项设置为 option_value 参数所指向的值。

level 参数指定选项所在的协议级别。要在套接字级别设置选项,level参数为 SOL_SOCKET。如果·要在其他级别设置选项,控制选项的协议需要提供适当的级别标识符。例如,要指示选项由 TCP(传输控制协议)解释,则level应设置为IPPROTO_TCP。

option_name 参数指定要设置的单个选项。 option_name 参数和任何指定的选项都直接传递给适当的协议模块,以进行解释。 <sys/socket.h> 头文件定义了套接字级别的选项。选项如下:

  1. SO_DEBUG
    打开调试信息的记录。此选项启用或禁用底层协议模块中的调试。此选项采用 int 值。这是一个布尔选项。
  2. SO_BROADCAST
    如果协议支持,则允许发送广播消息。此选项采用 int 值。这是一个布尔选项。
  3. SO_REUSEADDR
    指定用于验证提供给 bind() 的地址的规则,应该允许重用本地地址,如果协议支持的话。此选项采用 int 值。这是一个布尔选项。
  4. SO_KEEPALIVE
    如果协议支持,则通过启用消息的定期传输来保持连接处于活动状态。此选项采用 int 值。

如果连接的套接字未能响应这些消息,则连接中断,写入该套接字的线程会收到 SIGPIPE 信号通知。这是一个布尔选项。

  1. SO_LINGER
    如果数据存在,则停留在 close() 上。此选项控制在套接字上的未发送消息排队,并执行 close() 时采取的操作。如果设置了 SO_LINGER,系统将在 close() 期间阻塞进程,直到它可以传输数据或直到时间到期。如果未指定 SO_LINGER,并发出 close(),则系统以允许进程尽快继续的方式处理调用。此选项采用 <sys/socket.h> 标头中定义的延迟结构,以指定选项的状态和延迟间隔。
  2. SO_OOBINLINE
    叶子收到内联的带外数据(标记为紧急的数据)。此选项采用 int 值。这是一个布尔选项。
  3. SO_SNDBUF
    设置发送缓冲区大小。此选项采用 int 值。
  4. SO_RCVBUF
    设置接收缓冲区大小。此选项采用 int 值。
  5. SO_DONTROUTE
    请求传出消息绕过标准路由设施。目的地应位于直接连接的网络上,消息根据目的地地址定向到适当的网络接口。此选项的效果(如果有)取决于正在使用的协议。此选项采用 int 值。这是一个布尔选项。
  6. SO_RCVLOWAT
    设置要为套接字输入操作处理的最小字节数。 SO_RCVLOWAT 的默认值为 1。如果 SO_RCVLOWAT 设置为较大的值,阻塞接收调用通常会等待,直到它们收到低水位标记值或请求数量中的较小者。 (如果发生错误、捕获信号或接收队列中的下一个数据类型与返回的数据类型不同,它们可能返回小于低水位线;例如,带外数据。)此选项采用一个整数值。
  7. SO_RCVTIMEO
    设置超时值,该值指定输入函数在完成之前等待的最长时间。它接受带有秒数和微秒数的 timeval 结构,指定等待输入操作完成的时间限制。如果接收操作阻塞了这么长时间而没有接收到额外的数据,如果没有接收到数据,它将返回部分计数或设置为 [EAGAIN] 或 [EWOULDBLOCK] 的 errno。此选项的默认值为零,表示接收操作不会超时。此选项采用 timeval 结构。
  8. SO_SNDLOWAT
    设置要为套接字输出操作处理的最小字节数。如果流量控制不允许,处理发送低水位标记值或整个请求中的较小者,则非阻塞输出操作不应处理任何数据。此选项采用 int 值。请注意,并非所有实现都允许设置此选项。
  9. SO_SNDTIMEO
    设置超时值,指定输出功能阻塞的时间量,因为流控制阻止发送数据。如果发送操作在这段时间内被阻塞,如果没有数据发送,它将返回部分计数或将 errno 设置为 [EAGAIN] 或 [EWOULDBLOCK]。此选项的默认值为零,表示发送操作不会超时。

TCP层套接字行为控制代码

#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

using namespace std;
 
int main()
{
   int s;
   int optval;
   socklen_t optlen = sizeof(optval);

   /* Create the socket */
   if((s = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
      perror("socket()");
      return -1;
   }

   /* Check the status for the keepalive option */
   if(getsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &optval, &optlen) < 0) {
      perror("getsockopt()");
      close(s);
      return -1;
   }
   cout << "SO_KEEPALIVE is " << (optval ? "ON" : "OFF") << endl;

   /* Set the option active */
   optval = 1;
   optlen = sizeof(optval);
   if(setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &optval, optlen) < 0) {
      perror("setsockopt()");
      close(s);
      return -1;
   }
   cout << "SO_KEEPALIVE set on socket" << endl;

   /* Check the status again */
   if(getsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &optval, &optlen) < 0) {
      perror("getsockopt()");
      close(s);
      return -1;
   }
   cout << "SO_KEEPALIVE is " << (optval ? "ON" : "OFF") << endl;

   close(s);
   return 0;
}

输出结果

SO_KEEPALIVE is OFF
SO_KEEPALIVE set on socket
SO_KEEPALIVE is ON

客户/服务器(Client/Server)设计模型

TCP客户/服务器

服务器

服务器端代码

#include <iostream>

#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <thread>
#include <memory>
#include <map>
#include <mutex>

#define MAX 80
#define PORT 8080
#define SA struct sockaddr
 
using namespace std;

class chatgroup {
public:
	static shared_ptr<chatgroup> m_pChatGroup;
	static shared_ptr<chatgroup> getInstance()
	{		
		if (m_pChatGroup == nullptr) {
			m_pChatGroup = make_shared<chatgroup>();
		}
		return m_pChatGroup;
	}
	
	void add_new_member(string name, int socket_fd)
	{
		lock_guard<mutex>  lk(lock);
		members[name]=socket_fd;
	}

	void add_new_member(int snd_fd, int socket_fd)
	{
		lock_guard<mutex>  lk(lock);
		cout << "snd:" << snd_fd << "  rcv:"<<socket_fd<<endl;
		pair_members[snd_fd]=socket_fd;
	}
	
	int  find_member(string name)
	{
		lock_guard<mutex>  lk(lock);
		
		map<string, int>::iterator it;
		it = members.find(name);
		if (it == members.end())
			return -1;
		return it->second;
	}

	int  find_member(int snd_fd)
	{
		lock_guard<mutex>  lk(lock);
		
		map<int, int>::iterator it;
		it = pair_members.find(snd_fd);
		if (it == pair_members.end())
			return -1;
		return it->second;
	}
	
private:
	map<string, int>  members;
	map<int, int>  pair_members;
	
	mutex   lock;
};

shared_ptr<chatgroup> chatgroup::m_pChatGroup=nullptr;

// Function designed for chat between client and server.
void task_server(int sockfd)
{
    char buff[MAX];
    int n;

	shared_ptr<chatgroup> m_pInst = chatgroup::getInstance();
	
    for (;;) {
        bzero(buff, MAX);
  
        read(sockfd, buff, sizeof(buff));
		cout << "From client: " << buff << endl;
		
		buff[strlen(buff)-1] = '\0';
		string s(buff);
		size_t found = s.find("login:", 0);
		if (found!=string::npos) {
			string name=s.substr(found+6);
			cout << "name: " << name << endl;
			m_pInst->add_new_member(name, sockfd);
			strcat(buff, ",  this is server reply\n");
			write(sockfd, buff, sizeof(buff));
			continue;
		} 
		
		found = s.find("sendto:", 0);
		if (found!=string::npos) {
			string name=s.substr(found+7);
			cout << "name: " << name << endl;
			int fd = m_pInst->find_member(name);
			if (sockfd == fd)
			{
				strcpy(buff, "you can't talk to yourself\n");				
			}
			else if (fd == -1) {
				sprintf(buff, "%s doesn't exist\n", name.c_str());
			}
			else
			{
				strcpy(buff, "done\n");
				m_pInst->add_new_member(sockfd, fd);
			}
			write(sockfd, buff, sizeof(buff));
			continue;				
		}
		
		found = s.find("msg:", 0);
		if (found!=string::npos) {
			string msg=s.substr(found+4);
			cout << "msg: " << msg << endl;
			
			int fd = m_pInst->find_member(sockfd);
			write(fd, msg.c_str(), msg.length());
			cout << "send msg to: " << fd << endl;
			
			strcpy(buff, "done\n");			
			write(sockfd, buff, strlen(buff));
			
			continue;				
		}
	  
		strcat(buff, "whom you want to talk?\n");
        n = 0;

        write(sockfd, buff, sizeof(buff));
  
        if (strncmp("exit", buff, 4) == 0) {
            cout << "Server Exit..." << endl;
            break;
        }
    }
}
  
int main()
{
    int sockfd, connfd;
    struct sockaddr_in servaddr, cli;
	socklen_t  len;
	
    // socket create and verification
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        cout << "socket creation failed..." << endl;
        exit(0);
    }
    
	cout << "Socket successfully created.." << endl;
	
    bzero(&servaddr, sizeof(servaddr));
  
    // assign IP, PORT
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(PORT);
  
    // Binding newly created socket to given IP and verification
    if ((bind(sockfd, (SA*)&servaddr, sizeof(servaddr))) != 0) {
        cout << "socket bind failed..." << endl;
        exit(0);
    }
    
	cout << "Socket successfully binded.." << endl;
  
    // Now server is ready to listen and verification
	while (1)
	{
		if ((listen(sockfd, 5)) != 0) {
			printf("Listen failed...\n");
			break;
		}
		
		cout << "Server listening.." << endl;
		len = sizeof(cli);
	  
		// Accept the data packet from client and verification
		connfd = accept(sockfd, (SA*)&cli, &len);
		if (connfd < 0) {
			printf("server acccept failed...\n");
			continue;
		}
		thread(task_server, connfd).detach();
	}
   
    close(sockfd);
}

客户

客户端代码

#include <iostream>
#include <unistd.h>

#include <string.h>
#include <arpa/inet.h>

#define MAX 80
#define PORT 8080
#define SA struct sockaddr

using namespace std;

void task_client(int sockfd, char *name)
{
    char buff[MAX];
    int n;

	sprintf(buff, "login:%s\n", name);
    write(sockfd, buff, sizeof(buff));
    bzero(buff, sizeof(buff));
    read(sockfd, buff, sizeof(buff));
    printf("From Server : %s", buff);

    for (;;) {
        bzero(buff, sizeof(buff));
        printf("Enter the string : ");
        n = 0;
        while ((buff[n++] = getchar()) != '\n')
            ;
        write(sockfd, buff, sizeof(buff));
		
        bzero(buff, sizeof(buff));
        read(sockfd, buff, sizeof(buff));
        printf("From Server : %s", buff);
        if ((strncmp(buff, "exit", 4)) == 0) {
            printf("Client Exit...\n");
            break;
        }
    }
}
  
int main(int argc, char *argv[])
{
    int sockfd, connfd;
    struct sockaddr_in servaddr, cli;

	if (argc != 2)
	{
		cout << "Usage:" << endl;
		cout << "   tcp_client.bin port name" << endl;
		exit(0);
	}
	
    // socket create and varification
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        cout << "socket creation failed..." << endl;
        exit(0);
    }
    
	cout << "Socket successfully created.." << endl;
    bzero(&servaddr, sizeof(servaddr));
  
    // assign IP, PORT
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
    servaddr.sin_port = htons(PORT);
  
    // connect the client socket to server socket
    if (connect(sockfd, (SA*)&servaddr, sizeof(servaddr)) != 0) {
        printf("connection with the server failed...\n");
        exit(0);
    }
    
	cout << "connected to the server.." << endl;
  
    // function for chat
    task_client(sockfd, argv[1]);
  
    // close the socket
    close(sockfd);
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值