TCP协议

0. 前言

TCP协议全称为传输控制协议Transmission Control Protocol

在应用层调用类似writerendsendrecv这些系统调用发送数据,本质并不是将数据发送到网络当中,而是将数据从用户缓冲区拷贝到tcp的发送缓冲区(内核)。

至于这个数据什么时候发送、发送了多少、出现问题怎么办,这个完全由操作系统,即TCP协议自己决定

这就有点像系统的文件操作了,往文件里面写数据,本质就是从用户级缓冲区往文件缓冲区里面写数据

这些数据什么时候刷新,是由操作系统和磁盘进行交互的。

对于网络只不过是将磁盘换成了网卡而已

image-20240318145659569

这也就是传输控制协议里控制的道理,即控制数据的发送接收问题

1. TCP协议段格式

不同层不同名字:

  • 应用层数据:请求和响应
  • 传输层数据:数据段
  • 网络层数据:数据报
  • 数据链路层数据:数据帧

image-20240319104334432

牢牢记住:

TCP协议发送信息,每次必定携带完整报头!

1.1 如何交付给上层?

前两个字段就能知道要交付给上层的哪个协议,然后操作系统通过目的端口号就能找到目标进程

1.2 报头和有效载荷如何分离?

tcp报头标准格式为20字节,在这里面有一个叫4位首部长度的字段,指的是报头的总长度

4位的取值范围就是[0,15],这里约定了计算的时候有基本的单位:4byte,所以真实的取值范围是[0,60]

标准报头20字节,剩下的就是选项。

例如首部长度为5,5*4byte=20,那么报头就是20,没有选项;

首部长度假设为6,6*4byte=24,减去标准报头20,还剩下4字节,剩下的就是选项内容

这里采用的就是固定长度+自述字段

1.3 16位窗口大小

假设客户端在应用层构建了一个HTTP请求,这里面包含了各种信息(请求行、请求报头、请求正文…),通过系统调用将这个写入到了传输层(TCP)的发送缓冲区,在本层要添加TCP协议的报头字段,一系列向下交付之后,发给服务端,到达服务端的传输层,就能拿到http请求和tcp的报头

客户端和服务端基于tcp通信时,发送内容里面必定携带着完整的tcp报头

如果此时应用层出于一些原因没有读取接收缓冲区的数据,导致接收缓冲区被写满,之后来的数据就丢包了,但由于tcp是可靠通信,出现这种情况的时候,就需要让对方知道自己的接收缓冲区即将写满,让对方发送减慢或者是停止,这种叫做流量控制

怎么让对方知道呢?

tcp协议有确认应答机制,这也是tcp可靠性的一种体现。

客户端给服务端发送消息,服务端收到之后作出响应,反之也是

这个发送的快慢,是由接收方的接收缓冲区还要多少剩余空间决定,而这个剩余空间大小是填充在tcp报头中16位窗口大小这个字段的!

1.4 32位序号 & 32位确认序号

世界上并不存在100%可靠的网络协议,但是在局部上可以实现

客户端给服务端发送消息,服务端收到之后给客户端做出应答,就能保证客户端->服务端的通信可靠;

tcp协议双方是平等的,客户端可以给服务端发信息,服务端也可以给客户端发信息

服务端给客户端发信息,客户端收到之后给服务端做出应答,就能保证服务端->客户端的通信可靠。

无需对应答再做应答,不然就会出现先有鸡还是先有蛋的问题了

如果发出消息之后,过了一段时间,没收到应答,那么就会被认为数据丢包,采取重传

当然,正常的通信并不是一问一答,比如说:

A:今天天气真好,我们出去玩吧?(消息)

B:好啊(应答),我们去一个在下雨的地方吧(消息)

A:行啊(应答),我看看哪里在下雨(消息)

在应答的时候,还能携带消息,这叫捎带应答

tcp最原始的通信过程:

image-20240319094007875

这样的通信方式肯定是可以的,但这样都是串行的,客服端只能给服务端一条一条的发送消息,效率较低

对应主流的tcp协议,客户端可以发送一批消息,然后服务端对历史的报文做出应答即可

这样又面临着消息顺序性的问题,出现消息乱序,也是不可靠的一种表现

tcp打的招牌就是可靠,所以在tcp的报头当中有一个32位序号字段,将每个字节编号:

对于tcp发送缓冲区,可以看作一个char outbuffer[N]数组

image-20240319095434079

这样每个字节就有自己的编号(数组下标天然编号)

对于服务端的响应,要表明自己收到了哪些序号,tcp报头的32位确认序号里面填充的就是收到报文的序号+1

**确认序号的意义:**表明确认序号之前的序号都收到,下次发送从该序号发送

image-20240319103021366

这样可以允许少量的应答报文丢失,例如说数据1000应答没收到,但是之后有3000数据的应答,这也表明1000数据收到,因为定义就是确认序号之前的数据全部收到

这里即有序号,又有确认序号是因为双方是平等的,既可以应答,也可以发送消息

报文里面既携带了数据(序号),也携带了应答(确认序号)

1.5 16位紧急指针

在本次报文中包含紧急数据的偏移量是多少

例如数据携带了1000,紧急指针字段填的是500,就表明在这个位置有紧急数据,需要高优先级处理

这个紧急数据默认只能携带一个字节

在特殊情况下使用,例如:

当服务端在处理某个计算量较大的任务时占用了较大的资源,客户端发起请求时响应较慢或者没响应,但是客服端那边并不知道服务器是什么情况,于是客户端便可以发送紧急数据询问(前提是服务端支持读取紧急数据,对某种状态有对应状态码),服务端有专门处理紧急数据的服务,因为处理的信息量不大(最多携带一个字节),然后返回当前状态

1.6 六个标志位

tcp通信之前要建立连接、然后正常的数据通信,最后还要断开连接,这些过程都是要携带完整的tcp报文的

这就表明tcp收到的报文一定是有类型的,不同的类型决定着要做出不同的响应

而这6个标志位就表明了tcp报文的类型:

tcp报头和配套的协议都是由操作系统自己决定的,这些并不会暴露给用户,让用户直接修改

提供系统调用,让用户间接修改

  • ACK(acknowledgement)
    默认置1,表明确认序号是否有效,报文具有应答属性

  • SYN(synchronous)
    请求建立连接(三次握手)

  • FIN(finish)
    断开连接

  • PSH(push)
    这里就涉及到上面讲的16位窗口大小,当对方接收缓冲区空间不足的时候,发送方会减缓或停止发送,如果对方一直不通知可以发送,发送方则设置psh标记位,表明接收方需尽快将接收缓冲区内容读走

  • RST(reset)
    要求对方重新建立连接(重新三次握手),复位报文段

    tcp虽然是保证可靠性,但是tcp允许建立连接失败

    作为服务端,是存在多个建立好的连接的(多个客户端连接该服务),对于这些连接需要进行管理维护——先描述再组织(客服端服务端都是)

    所以维护这些连接是有成本的,在大量客户端发起连接的时候,可能就会出现连接异常的情况

    image-20240319180537505

    tcp建立连接时会三次握手,可是第三次握手是没有应答,当第三次客户端发送ack的时候,客户端认为当前已经建立好连接;当服务端收到ack的时候,也表明服务端建立好连接,这中间是有一定的时间窗口的。但是如果这个ack丢失了!那么就会出现客户端服务端对于连接是否建立成功认知不一致的情况。

    客户端以为自己已经建立成功,便开始发送正常数据,可是到服务段这边,满脑子问号???不是说好通信之前要三次握手吗?为什么才2次握手就开始通信了???于是服务端要告诉客户端,我们目前还没建立连接成功,设置RST标志位。客户端收到之后便重新发起连接请求。

  • URG(urgent)
    紧急标志位,紧急指针是否有效

    tcp发送的数据是有序的,所以默认是不存在数据插队的情况,可是在某些情况下需要高优先级处理某些数据,此时就可设置urg标志位,表明报文里面有紧急数据,16位紧急指针有效。

2. 确认应答机制

确认应答机制就是ACK,发送的数据携带序号,对方收到之后将数据+1表明在该序号之前的数据前部收到

3. 超时重传机制

主机对于发送出去的数据是否丢失,无法判定,所以必须规定——特定的时间间隔,表征是否需要重传

在没有收到应答之前,并不知道这个报文是丢了还是被阻塞住了,只有一种情况是清楚的,既收到应答

场景一:报文丢失

image-20240319184425398

场景二:应答丢失

image-20240319185214909

对于场景一发送的数据丢包了,超过等待时间之后,重新补发即可;

但是对于场景二应答丢失,主机A补发,但是这个数据是重复的报文,不过由于报文有序号,主机B会进行验证,如果有该报文的序号(保证不会收到重复的报文),则不接收,重新发送应答

超时的时间间隔设置:

  • 网络情况理想的情况下,时间间隔设置小一点
  • 网络情况不理想的情况下,时间间隔设置可以稍微长一点

所以这个时间间隔是动态的,在Linux中是以500ms位一个单位控制的,每次判定超时重发的时间都是500ms的整数倍

  • 如果重发一次,未得到应答,等待2*500ms后重发;

  • 还未得到应答,等待4*500ms后重发(以指数形式增长)

  • 累计到一定次数,TCP认为对方主机有问题,直接强制断开连接

4. 连接管理机制

单独写了一篇:TCP协议——三次握手和四次挥手

5. 流量控制

双方在通信的时候,需要先三次握手成功建立连接,那第一次发送数据的时候怎么保证发送数据的大小呢(第一次发送可能不知道对方的接收能力)?

实际上三次握手不仅仅只是为了建立连接,双方发送的都是完整的报文,在此期间双发已经协商了对方的接收能力

就好比相亲见面:

将见面比作开始正常通信,在此之前需要双方家长建立连接,但是这并不是简单的建立连接,而是看对方各方面的条件,感觉合适,才会让孩子们见面

TCP在保证可靠性的同时,也要保证效率,在三次握手时,前两次握手不允许携带数据!因为还没有建立连接成功,在第三次发送ACK的时候,一段已经建立连接成功,可以捎带应答发送ACK+数据,这就表明协商的过程在前两次握手就完成了。

image-20240321150155459

如上图,假设A主机给B主机连续发送几个报文时,当B主机的接收缓冲区满时,根据流量控制A主机就不发了,等B主机上层从接收缓冲区拿数据。

但A主机不会一直等,而且A主机和B主机是两台机器,就算B主机更新了,A主机也不知道,所以A主机可以给B主机发送窗口探测报文(超过了重传时间),B主机必须应答,告诉当前16位窗口大小;当然B主机窗口更新时,也会主动给A主机发送报文。

这样一个主动问,一个主动答,谁先到达就以谁的为准,这样就既保证了可靠性,又保证了效率。

窗口探测和窗口更新都是有应答的,如果因为网络问题,A主机的探测报文发出去没有应答,或者是主机B的更新报文发出去没有应答,这就在侧面证明双方网络出现了问题,经过一段时间的重传之后,就自动关闭了这个异常连接

这里窗口大小是16位,即2^16^ = 65,536,表示范围就是[0, 65,535],大概就是64k的数据,这就表明默认情况下接收缓冲区的大小就是64k,但是TCP首部里面除了默认标准字段,还有选项字段,里面包含了一个窗口扩大因子M,假设M = 2,就代表这实际窗口大小左移2位,即65,535 * 4,约为256k

6. 滑动窗口

算法中的滑动窗口和xx类似:

滑动窗口算法

TCP通信的时候是知道对方的接收能力的,所以可以一次性批量发送报文,这样可以提高一定的效率,然后再由接收方对每个报文进行应答。

对于发送出去的报文,如果暂时没有收到应答,是要被TCP保存起来的(防止没有应答,然后进行超时重传)

这一批没有收到应答的报文,被保存在哪儿?

tcp发送方要发送的数据,原本就在发送缓冲区,就算要发送,也是拷贝到底层,这个数据还是在发送缓冲区的,所以就压根不需要对其进行保存!因为原本就是被保存起来的!

要做的只是将发送缓冲区的内容进行区域划分:

  • **已发送,已确认数据:**发送的数据收到了应答,这块区域可以被覆盖,继续写入数据(计算机里面删除的本质就是该区域数

    据无效,可以被其他数据覆盖)

  • **待发送数据:**还未发送的数据

  • **已发送,未确认数据:**发送的数据还未收到应答,该区域还是被维持着的。
    如果收到了应答,就把收到应答的部分移到前一个区域;

    如果待发送区域的数据发送了,那么就把那个部分的数据移到该区域

    这个区域就是滑动窗口

image-20240321155530248

所以,滑动窗口是在发送缓存区的一部分

6.1 丢包问题

应答丢失:

image-20240322105230017

上图,滑动窗口里1~10003001~40004001~5000的应答报文看似都丢失了

但是确认序号的定义是:确认序号是x,表示**x之前的报文已经全部收到!**

例如中间3001~4000的确认应答丢失,但是后面收到了5001~6000的报文,这就表示已经收到了3001~4000字节的数据

因为有了确认序号,所以对于滑动窗口来说能够线性确认哪些报文已经收到

数据丢失:

由于确认序号的存在,能保证滑动窗口不会出现跳过的情况,所以也不用担心中间的数据丢失,滑动窗口出现遗漏的情况

image-20240322110535238

关于补发问题:

当数据包丢失的时候,发送方收到三次同样的确认应答时,此时立即进行重发,这叫快重传

为什么有了快重传,还要有超时重传?

快重传是有触发条件的,即连续收到三次同样的确认应答,那如果这个丢失的报文正好是末尾的数据,那就没那么多应答了,就不会触发这个条件。

所以快重传是解决效率问题,而超时重传是兜底的

6.2 滑动问题

滑动窗口是单向滑动的,且只能向右滑动(因为左侧表示已经发送且收到应答的数据)

三种滑动情况:

  • 右不变,左移动(接收方上层不取数据),窗口变小
  • 左右都移动,窗口变大(取决于接收方上层接收能力)
  • 左右都移动,窗口变小(取决于接收方上层接收能力)
int start = 确认序号;
int end = 确认序号 + min(win大小, 有效数据)

流量控制就是通过滑动窗口实现的

对方主机来不及接收,滑动窗口缩小,发送量减小;对方主机接收能力强,滑动窗口扩大,发送量增加

6.3 越界问题

这里采用的是环状数组的方式(取模运算),物理上是线性结构,逻辑上是环形结构,所以不必担心越界问题

关于起始序号协商:

三次握手的时候会协商起始序号,这个序号是随机的,然后选取其中较小的那一个作为起始序号

假设协商的起始序号位927,那么发送时最终序号就是927 + 数组下标,收数据就是确认序号 - 927,这样知道了下次发送的数据在缓冲区的位置。

有了随机序号+TIME_WAIT,就能极大程度保证历史数据影响新连接的情况

7. 延迟应答

客户端与服务端进行通信的时候,客户端给服务端发消息,服务端要给客户端进行应答,理论上来说:

  • 发送方一次性发送的数据越多,效率就越高;
  • 而能够发送多少数据,取决于对方通知能接收多少数据

所以对于接收方来说,如何给发送方通告一个更大的窗口呢?

接收方收到应答之后,不着急应答,给上层充分的时间拿走数据,这样窗口大小就增大了,这就是延迟应答的原理。

但并不是说不着急应答就必增加效率,这样也是有概率性的(假如上层没有拿走数据)

这样延迟多久是TCP决定的,我们上层编写程序能做的就是通过readrecv等接口尽快从内核将数据读走

延迟应答原则:

  • 数量限制:每个N个包就应答一次,一般N = 2
  • 时间限制:超过最大延迟应答时间就应答一次(这个时间小于超时重传时间),一般时间为200ms

8. 捎带应答

这个上面已经提过很多了…

TCP不仅仅是为了可靠而可靠,在保证可靠的同时,也要保证效率!

9. 拥塞控制

虽然TCP有了滑动窗口可以高效可靠发送大量数据,这仅仅是考虑主机与主机之间的联系,但在这个中间还要通过网络。

例如:双方连接正常的情况下,如果出现少量的丢包,那可能是主机的问题;那如果出现了大量的丢包,那可能就不是双方主机之间的问题了

期末考试举例:

假设一个班30人,期末考试时只有3个人挂科,那很正常;那如果班就3个人及格,那学校就得找任课老师谈话了

如果双方通信出现大量报文丢失,TCP会判断网络出现了问题,这叫做网络拥塞

如果是网络全部瘫痪了,此时只能找网络工程师过来解决;

如果是软件出现问题,那可以通过一定的技术手段避免

当发送方出现大量的丢包,那此时是否可以进行超时重传?

答案是不可以!

已经出现了大面积丢包,就可以大致判断是网络的问题,此时要是网络硬件出现问题,再怎么发也是徒劳;如果是因为数据量太大引起的阻塞,再继续重发,就又加重了网络的阻塞

对于拥塞控制,就不能仅仅考虑客户端服务端双方了!网络是共享的!

image-20240322121949434

都是采用tcp/ip协议,当网络出现拥塞的时候,识别到拥塞的每台主机要减少自己的发送数据量,这样就能将网络的历史数据加快消散

本质是用TCP协议实现了多台主机面对网络拥塞的**“共识”**

当然也并不是当网络拥塞的时候所以主机都能判断出来,比如说A主机指向网络发送了几个报文,判断不出来。

所以发现拥塞的主机参与到这个过程,其他的主机正常运行

基于上面的问题TCP就采用了慢启动机制:先发送少量的数据探路,根据网络状态再决定按照多大的数据传输数据

拥塞窗口:

  • 开始发送时,拥塞窗口大小为1

  • 每收到一个应答,拥塞窗口大小+1

  • 每次发送数据包的时候,将拥塞窗口大小与接收端窗口大小作比较,选取较小的那一个作为时间的发送窗口

    //滑动窗口大小2.0
    int start = 确认序号;
    int end = 确认序号 + min(win大小, 有效数据, 拥塞窗口);
    

慢启动只是初始较慢,但是增长是指数级增长,当然这个实际发送的数据量并不会一直增长(有滑动窗口和阈值)

网络出现拥塞,发送少量的报文,如果没什么问题,就表明网络已经健康,此时就应该尽快恢复正常通信

慢启动阈值:

  • 为了不增长的那么快,这里设置了一个阈值,当拥塞窗口超过这个阈值的时候,不再按照指数增长,而是按照线性方式增长(如下图,图片来源百度百科)

image-20240322124217612

  • 慢启动阈值为最近一次发送网络拥塞时拥塞窗口大小*0.5,同时拥塞窗口置1

TCP拥塞控制,就好像是谈恋爱

10. 面向字节流

技术层面:

有缓冲区的存在,TCP的读和写不需要一一匹配:

  • 写100个字节数据,可以调用一次write一次性写100个字节,也可调用50次write,每次写2个字节
  • 读100个字节数据,可以调用一次read一次性读100个字节,也可以调用100次read,每次读1个字节

内核:

在用户层发送的请求,并不是直接发送到对方的接收缓冲区,平时用的readwrite接口本质是将数据拷贝到tcp发送缓冲区当中,这些数据按照字节的顺序在发送缓冲区里面放着,什么时候发、怎么发,完全由tcp自主决定

如果说要发送前1000字节的内容,直接从缓冲区里面取数据,然后封装报头,向对方的接收缓冲区发送即可。而接收缓冲区的数据,取走多少,由用户决定(拿一半的报文、整个报文、一个半报文…)

数据从上层进入发送缓冲区,再流到接收缓冲区,然后上层再取走,数据一直是流动的,所以才叫做字节流

11. 数据包粘包

由于tcp是面向字节流,用户以为自己发送的是一个或者多个完整的请求,但是在内核,只认识多少个字节,并不关系上层协议

接收方的用户层拿到的可能就不是完整的报文,如果直接将这半个报文丢弃,就会影响到后续的报文,这种情况叫做数据包粘包

这是用户层的概念,是由用户层来解决的,所以就需要用户来**定协议!**明确报文和报文之间的边界

  1. 定长报文
  2. 使用特殊字符
  3. 自描述字段 + 定长报头(特殊字符)

当报文和报文分离之后,再进行序反序列化:Linux网络编程——序列反序列化

#pragma once

#include<iostream>
#include<jsoncpp/json/json.h>
//#define Myself 1

/*
    表示层
    需要根据不同的应用场景自己定制,所以没办法在内核里直接实现
*/

const std::string blank_space_sep = " ";
const std::string protocol_sep = "\n";

//添加报头
std::string Encode(std::string &content)
{
    std::string package = std::to_string(content.size());
    package += protocol_sep;
    package += content;
    package += protocol_sep;
    return package;
}

//去掉报头
bool Decode(std::string &package, std::string *content)
{
    //"len"\n"x op y"\n
    size_t pos = package.find(protocol_sep);
    if(pos == std::string::npos)    return false;
    std::string len_str = package.substr(0, pos);
    size_t len = std::stoi(len_str);
    size_t total_len = len_str.size() + len + 2;
    if(package.size() < total_len)  return false;
    
    *content = package.substr(pos+1, len);
    
    //移除已提取报文
    package.erase(0, total_len);
    return true;
}

class Request
{
public:
    Request(int data1, int data2, char op)
    :data1_(data1),data2_(data2),op_(op)
    {}
    Request()
    {}
public:
    //序列化
    bool Serialize(std::string *out)
    {
#ifdef Myself
        //有效载荷
        //x op y
        std::string s = std::to_string(data1_);
        s += blank_space_sep;
        s += op_;
        s += blank_space_sep;
        s += std::to_string(data2_);
        
        *out = s;
        
        return true;
#else
        Json::Value root;
        root["data1"] = data1_;
        root["data2"] = data2_;
        root["op"] = op_;
        Json::FastWriter fw;
        *out = fw.write(root);
        return true;
#endif
    }
    //反序列化
    bool Deserialize(const std::string &in)
    {
#ifdef Myself
        //"data1 op data2"
        size_t leftpos = in.find(blank_space_sep);
        if(leftpos == std::string::npos)    return false;
        std::string part1 = in.substr(0, leftpos);

        size_t rightpos = in.rfind(blank_space_sep);
        if(rightpos == std::string::npos)   return false;
        std::string part2 = in.substr(rightpos+1);

        if(leftpos+1 != rightpos-1)    return false;
        op_ = in[leftpos+1];
        data1_ = std::stoi(part1);
        data2_ = std::stoi(part2);
        
        return true;
#else
        Json::Value root;
        Json::Reader r;
        r.parse(in, root);

        data1_ = root["data1"].asInt();
        data2_ = root["data2"].asInt();
        op_ = root["op"].asInt();
        return true;
#endif
    }

    void DebugPrint()
    {
        std::cout << "receive a request: " << data1_ << op_ << data2_ << std::endl;
    }
public:
    int data1_;
    int data2_;
    char op_;
};


class Response
{
public:
    Response(int res, int code)
    :result_(res),exitcode_(code)
    {}
    Response()
    {}
public:
    //序列化
    bool Serialize(std::string *out)
    {
#ifdef Myself
        //有效载荷
        //"result exitcode"
        std::string s = std::to_string(result_);
        s += blank_space_sep;
        s += std::to_string(exitcode_);

        *out = s;
        
        return true;
#else
        Json::Value root;
        root["result"] = result_;
        root["exitcode"] = exitcode_;
        Json::FastWriter fw;
        *out = fw.write(root);
        return true;
#endif
    }
    //反序列化
    bool Deserialize(const std::string &in)
    {
#ifdef Myself
        //"result exitcode"
        size_t pos = in.find(blank_space_sep);
        if(pos == std::string::npos)    return false;
        std::string part1 = in.substr(0, pos);
        std::string part2 = in.substr(pos+1);

        result_ = std::stoi(part1);
        exitcode_ = std::stoi(part2);
        
        return true;
#else
        Json::Value root;
        Json::Reader r;
        r.parse(in, root);

        result_ = root["result"].asInt();
        exitcode_ = root["exitcode"].asInt();
        return true;
#endif
    }

    void DebugPrint()
    {
        std::cout << "ret calc done, result: " << result_ << " exitcode: " << exitcode_ << std::endl;
    }

public:
    int result_;
    int exitcode_;
};

12. TCP异常

  1. 进程终止: 连接是由用户connect发起,之后的握手挥手由操作系统完成,所以当进程终止之后,操作系统会自动继续四次挥手

  2. 机器重启: 在电脑进行关机的时候,系统会提示xxx还未保存...,这就意味着在重启或者关机的时候,操作系统要杀掉所有的进程,杀掉进程,这就和第一种情况一样了

  3. 掉线: 当客户端掉线之后,客户端网络就断开了,之后根本就没有机会向服务器发起四次挥手的;可是服务器并不知道客户端掉线,连接正常维护;当客户端重新连接上来之后,但是这是一个新连接,而服务端还是那个老连接,这就是连接认知不一致,所以服务端会给客服端发送RST,重新建立

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

加法器+

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值