UDP通信

一、网络通信基础

理解IP和端口

https://zhuanlan.zhihu.com/p/90024250

IP地址是一个规定,现在使用的是IPv4,既由4个0-255之间的数字组成,在计算机内部存储时只需要4个字节即可。在计算机中,IP地址是分配给网卡的,每个网卡有一个唯一的IP地址,如果一个计算机有多个网卡,则该台计算机则拥有多个不同的IP地址,在同一个网络内部,IP地址不能相同。IP地址的概念类似于电话号码、身份证这样的概念。由于IP地址不方便记忆,所以有专门创造了域名(Domain Name)的概念,其实就是给IP取一个字符的名字,例如http://163.com、http://sina.com等。IP和域名之间存在一定的对应关系。如果把IP地址类比成身份证号的话,那么域名就是你的姓名。 其实在网络中只能使用IP地址进行数据传输,所以在传输以前,需要把域名转换为IP,这个由称作DNS的服务器专门来完成。所以在网络编程中,可以使用IP或域名来标识网络上的一台设备。

一台拥有IP地址的主机可以提供许多服务,IP 地址与网络服务的关系是一对多的关系,通过“IP地址+端口号”来区分不同的服务。 为了在一台设备上可以运行多个程序,人为的设计了端口(Port)的概念,类似的例子是公司内部的分机号码。

二、基本的udp socket编程

详细内容见:https://zhuanlan.zhihu.com/p/471327719

1. UDP编程框架

UDP(user datagram protocol)的中文叫用户数据报协议,属于传输层。UDP是面向非连接的协议,它不与对方建立连接,而是直接把我要发的数据报发给对方。所以UDP适用于一次传输数据量很少、对可靠性要求不高的或对实时性要求高的应用场景。正因为UDP无需建立类如三次握手的连接,而使得通信效率很高。
在这里插入图片描述
由以上框图可以看出,客户端要发起一次请求,仅仅需要两个步骤(socket和sendto),而服务器端也仅仅需要三个步骤即可接收到来自客户端的消息(socket、bind、recvfrom)。

2. UDP程序设计常用函数

#include <sys/types.h>          
#include <sys/socket.h>

struct sockaddr_in结构体

结构体的定义如下:
struct sockaddr_in {
    short sin_family;           // 地址族,一般设为 AF_INET
    unsigned short sin_port;    // 端口号,使用网络字节序表示
    struct in_addr sin_addr;    // IP 地址
    char sin_zero[8];           // 用于填充,使结构体与 sockaddr 保持一致
};

sin_family:地址族,一般设置为 AF_INET 表示 IPv4 地址。
sin_port:端口号,使用网络字节序(big-endian)表示。可以使用 htons 函数将主机字节序转换为网络字节序。
sin_addr:IP 地址,是一个 struct in_addr 结构体,表示 IPv4 地址。
sin_zero:用于填充,使 sockaddr_in 的大小与 sockaddr 结构体保持一致,在使用时可以将其设置为 0

inet_addr、inet_aton

in_addr_t inet_addr (const char *__cp):
将以数字和点号表示的互联网主机地址转换为网络字节顺序中的二进制数据。
int inet_aton (const char *__cp, struct in_addr *__inp):
将以点分十进制表示的互联网主机地址转换为二进制数据,并将结果存储在结构体 __inp 中。

socket函数

int socket(int domain, int type, int protocol);
1.参数domain:用于设置网络通信的域,socket根据这个参数选择信息协议的族。
对于该参数我们仅需熟记AF_INET和AF_INET6即可。
Name                                     Purpose                         
AF_UNIX, AF_LOCAL          Local communication              
AF_INET                           IPv4 Internet protocols          //用于IPV4
AF_INET6                         IPv6 Internet protocols          //用于IPV6
AF_IPX                             IPX - Novell protocols
AF_NETLINK                     Kernel user interface device     
AF_X25                            ITU-T X.25 / ISO-8208 protocol   
AF_AX25                          Amateur radio AX.25 protocol
AF_ATMPVC                      Access to raw ATM PVCs
AF_APPLETALK                 AppleTalk                        
AF_PACKET                      Low level packet interface       
AF_ALG                           Interface to kernel crypto API

2.参数type(只列出最重要的三个):
SOCK_STREAM Provides sequenced, reliable, two-way, connection-based byte streams. //用于TCP
SOCK_DGRAM Supports datagrams (connectionless, unreliable messages ). //用于UDP
SOCK_RAW Provides raw network protocol access. //RAW类型,用于提供原始网络访问

3.参数protocol:置0即可
返回值:成功:非负的文件描述符
失败:-1

sendto

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
              const struct sockaddr *dest_addr, socklen_t addrlen);
第一个参数sockfd:正在监听端口的套接口文件描述符,通过socket获得
第二个参数buf:发送缓冲区,往往是使用者定义的数组,该数组装有要发送的数据
第三个参数len:发送缓冲区的大小,单位是字节
第四个参数flags:0即可
第五个参数dest_addr:指向接收数据的主机地址信息的结构体,也就是该参数指定数据要发送到哪个主机哪个进程
第六个参数addrlen:表示第五个参数所指向内容的长度
返回值:成功:返回发送成功的数据长度
失败: -1

recvfrom函数

recv是个阻塞方法,当程序运行到recv时,它会一直等待,直到接收到数据才往下执行。

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                struct sockaddr *src_addr, socklen_t *addrlen);
第一个参数sockfd:正在监听端口的套接口文件描述符,通过socket获得
第二个参数buf:接收缓冲区,往往是使用者定义的数组,该数组装有接收到的数据
第三个参数len:接收缓冲区的大小,单位是字节
第四个参数flags:0即可
第五个参数src_addr:指向发送数据的主机地址信息的结构体,也就是我们可以从该参数获取到数据是谁发出的
第六个参数addrlen:表示第五个参数所指向内容的长度
返回值:成功:返回接收成功的数据长度
失败: -1

bind函数

函数将 sock 和 address绑定在一起,以便能够接收来自指定地址和端口的数据。

int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen);
第一个参数sockfd:正在监听端口的套接口文件描述符,通过socket获得
第二个参数my_addr:需要绑定的IP和端口
第三个参数addrlen:my_addr的结构体的大小
返回值:成功:0
失败:-1

memset

void* memset(void* ptr, int value, size_t num);ptr:指向要设置的内存区域的指针。
value:要设置的值,以整数形式表示,通常是 unsigned char 类型的值。
num:要设置的字节数。
例子:将结构体 send_addr 的内存区域清零。
memset(&send_addr, 0, sizeof(send_addr));

例子:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <thread>
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

//CRC校验,验证数据在传输、存储或接收过程中是否发生了错误或损坏
unsigned short computeCRC16(const unsigned char* ptr, int len)
{
    unsigned short crc16 = 0xffff;
    for (auto i = 0; i < len; ++i)
    {
        crc16 = *ptr ^ crc16;
        for (auto j = 0; j < 8; ++j)
        {
            auto tmp = crc16 & 0x0001;
            crc16 = crc16 >> 1;
            if (tmp)
            {
                crc16 = crc16 ^ 0xa001;
            }
        }
        *ptr++;
    }
    return crc16;
}

int main()
{
    //创建socket
    int sock_send = socket(AF_INET,SOCK_DGRAM, 0);
    //发送方地址
    struct sockaddr_in send_addr;
    memset(&send_addr, 0, sizeof(send_addr));
    //发送方地址族、端口号、地址。
    send_addr.sin_family = AF_INET;
    send_addr.sin_port = htons(3002);//htons从主机字节顺序转换成网络字节顺序
    std::string  ip_send = "127.0.0.1";
    const char* cStr_send = ip_send.c_str();
    send_addr.sin_addr.s_addr = inet_addr(cStr_send);//将一个IPv4的字符串形式的IP地址转换成一个32位的网络字节序的整型数

    //创建接收方socket
    int sock_rec = socket(AF_INET, SOCK_DGRAM, 0);
    //创建接受方地址sockaddr_in
    struct sockaddr_in rec_addr;
    memset(&rec_addr, 0, sizeof(rec_addr));
    //接收方地址族、端口、地址。端口和地址要转换成网络字节序
    rec_addr.sin_family = AF_INET;
    rec_addr.sin_port = htons(3002);
    std::string ip_rec = "127.0.0.1";
    const char* cStr_rec = ip_rec.c_str();
    rec_addr.sin_addr.s_addr = inet_addr(cStr_rec);

    //bind 函数将 sock_rec 和 rec_addr 绑定在一起,以便能够接收来自指定地址和端口的数据。
    bind(sock_rec, (struct sockaddr*)(&rec_addr), sizeof(rec_addr));

    //接受数据的缓冲区
    unsigned char buff_recv[512] = {0};
    struct sockaddr_in clientAddr;
    int len = sizeof(clientAddr);

    //
    unsigned char send_data[45] = {0};
    send_data[0] = 0xDD;
    send_data[1] = 0xFF;
    send_data[2] = 0x29;
    send_data[3] = 0x00;
    send_data[14] = 0x01;
    send_data[15] = 0x01;
    send_data[16] = 0x01;

    //从发送数据的第二个字节开始,每29个字节进行一次CRC校验
    auto send_crc16 = computeCRC16(send_data + 2, 29);
    send_data[41] = send_crc16;
    send_data[42] = send_crc16 >> 8;
    send_data[43] = 0xEE;
    send_data[44] = 0xEE;

    //定义了一个 lambda 表达式send,用于循环发送数据。
    auto send = [&]()
    {
        while(true)
        {
            std::cout << "Please input D for detect hook..." << std::endl;
            std::string input;
            std::cin >> input;
//            /当输入字符为 “D” 时,通过调用 sendto 函数将 send_data 发送到指定地址和端口。
            if(input == "D")
            {
                sendto(sock_send, send_data, 43, 0, (struct sockaddr*)&send_addr, sizeof(send_addr));
                std::cout << "send..." << std::endl;
            }
        }
    };

    //定义了一个 lambda 表达式recieve,用于循环接收数据。
    auto recieve = [&]()
    {
        while(true)
        {
            int n = recvfrom(sock_rec, buff_recv, 512, 0, (struct sockaddr*)(&clientAddr), (socklen_t*)(&len));

            if(n == 73 && buff_recv[0] == 0xDD && buff_recv[1] == 0xFF && buff_recv[14] == 0x02)
            {
                auto crc16 = computeCRC16(buff_recv + 2, 51);
                uint16_t recv_crc16 = (buff_recv[70] << 8) + buff_recv[69];

                if(crc16 == recv_crc16)
                {
                    int32_t error_code = 0, x = 0, y = 0, theta = 0;
                    for(size_t i = 0; i < 4; ++i)
                    {
                        error_code += (int32_t(buff_recv[25 + i]) << 8 * i);
                        x += (int32_t(buff_recv[29 + i]) << 8 * i);
                        y += (int32_t(buff_recv[33 + i]) << 8 * i);
                        theta += (int32_t(buff_recv[49 + i]) << 8 * i);
                    }
                    std::cout << "the error_code is: " << error_code << ", x is: " << x << " mm, y is: " << y << " mm, theta is: " << float(theta) / 1000.0f << std::endl;
                }
                else
                {
                    std::cout << "crc check fail..." << std::endl;
                }
            }
            std::this_thread::sleep_for(std::chrono::milliseconds(500));
        }
    };

    std::thread sendThd(send);
    std::thread recvThd(recieve);

    if(sendThd.joinable())
        sendThd.join();
    if(recvThd.joinable())
        recvThd.join();

    return 0;

}

三、Epoll入门理解及使用示例

一、epoll 做了什么

在 Linux 中,一个 I/O 代表着一个 fd。(fd: file description文件描述符)
通过调用 epoll 相关的 api,我们可以实现:被通知哪个 fd 可以进行读写了。
总结一下:就是 epoll 可以让我们在一个单线程内被通知哪个 fd 可以读写了,从而实现高效的读写 I/O。

二、epoll 的使用

先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。

https://blog.csdn.net/qq_35590267/article/details/122983150

1.创建epoll文件描述符

epoll_create(int size):size用来告诉内核这个监听的数目一共有多大。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

#include <stdio.h> // 用于 fprintf()
#include <unistd.h> // 用于 close()
#include <sys/epoll.h> // 用于 epoll_create1()
int main( ) 
{
  int epoll_fd = epoll_create1 ( 0 ) ;
 
  if(epoll_fd == - 1{
    fprintf ( stderr, "创建 epoll 文件描述符失败\n" ) ;
    return 1 ; 
  }
 
  if(close(epoll_fd ))
  {
    fprintf ( stderr, "无法关闭 epoll 文件描述符\n" ) ;
    return 1 ; 
  }
  return 0 ; 
}

2.为epoll添加需要监视的文件描述符(将需要监视的socket添加到epfd中)

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
第一个参数epfd是epoll_create()的返回值。
第二个参数op表示动作,用三个参数fd用宏来表示:
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数fd是需要监听的fd。
第四个参数event是告诉内核需要监听什么事。

#include <stdio.h>     // for fprintf()
#include <unistd.h>    // for close()
#include <sys/epoll.h> // for epoll_create1(), epoll_ctl(), struct epoll_event
 
int main()
{
  struct epoll_event event;
  int epoll_fd = epoll_create1(0);
 
  if(epoll_fd == -1)
  {
    fprintf(stderr, "Failed to create epoll file descriptor\n");
    return 1;
  }
 
  event.events = EPOLLIN;
  event.data.fd = 0;
 
  if(epoll_ctl(epoll_fd, EPOLL_CTL_ADD, 0, &event))
  {
    fprintf(stderr, "Failed to add file descriptor to epoll\n");
    close(epoll_fd);
    return 1;
  }
 
  if(close(epoll_fd))
  {
    fprintf(stderr, "Failed to close epoll file descriptor\n");
    return 1;
  }
  return 0;
}

3.等待被监听事件的发生

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
第一个参数epfd为函数epoll_create的返回值。
第二个参数events用来从内核得到事件的集合。
第三个参数maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size。
第四个参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)
返回值:epoll_wait() 的返回值表示事件数组中有多少成员被事件数据填充也就是需要处理的事件数目,如返回0表示已超时。

#define MAX_EVENTS 5
#define READ_SIZE 10
#include <stdio.h>     // for fprintf()
#include <unistd.h>    // for close(), read()
#include <sys/epoll.h> // for epoll_create1(), epoll_ctl(), struct epoll_event
#include <string.h>    // for strncmp
 
int main()
{
  int running = 1, event_count, i;
  size_t bytes_read;
  char read_buffer[READ_SIZE + 1];
  struct epoll_event event, events[MAX_EVENTS];
  int epoll_fd = epoll_create1(0);
 
  if(epoll_fd == -1)
  {
    fprintf(stderr, "Failed to create epoll file descriptor\n");
    return 1;
  }
 
  event.events = EPOLLIN;
  event.data.fd = 0;
 
  if(epoll_ctl(epoll_fd, EPOLL_CTL_ADD, 0, &event))
  {
    fprintf(stderr, "Failed to add file descriptor to epoll\n");
    close(epoll_fd);
    return 1;
  }
 
  while(running)
  {
    printf("\nPolling for input...\n");
    event_count = epoll_wait(epoll_fd, events, MAX_EVENTS, 30000);
    printf("%d ready events\n", event_count);
    for(i = 0; i < event_count; i++)
    {
      printf("Reading file descriptor '%d' -- ", events[i].data.fd);
      bytes_read = read(events[i].data.fd, read_buffer, READ_SIZE);
      printf("%zd bytes read.\n", bytes_read);
      read_buffer[bytes_read] = '\0';
      printf("Read '%s'\n", read_buffer);
 
      if(!strncmp(read_buffer, "stop\n", 5))
        running = 0;
    }
  }
 
  if(close(epoll_fd))
  {
    fprintf(stderr, "Failed to close epoll file descriptor\n");
    return 1;
  }
  return 0;
}

三、epoll原理详解

https://www.cnblogs.com/Hijack-you/p/13057792.html

四、大小端序及socket通信字节序问题

https://blog.csdn.net/s634772208/article/details/85042947

大端序(big-Endian):高字节保存在内存的低地址,低字节保存在内存的高地址。
小端序(little-Endian):高字节保存在内存的高地址,低字节保存在内存的低地址。
理解:
大端:大个子的开头,即高字节保存在内存的低地址
小端:小个子的开头,即低字节保存在内存的低地址

IP/TCP标准说传输时采用网络字节序,主要是为了解决不同平台之间的数据传输问题,如果要遵循这个标准的话,那么在send发送数据前就要调用htonl、htons等函数将本机字节序数据转化面网络字节序,在recv接收数据后,就要调用ntohl、ntohs等函数将网络字节序数据转化成本机字节序数据了

因为现在大多数机器的CPU架构都是基于x86 (Intel、AMD等)体系的,故代码中就未考虑字节序的问题了(认为都是一样的字节序架构体系),故在send、recv等函数中用户层就没有再去htonl、htons、ntohl、htohs等函数了。从这可得知,若服务端运行在小端序机器,客户端运行在大端序机器,不考虑字节序问题的话,那结果就是不能工作了。
在这里插入图片描述

注:如果发送的是字符串,即在send前用sprintf等函数将数据全部转换成字符串,recv的也是字符串,然后在用户层自已去解析这些字符串,该转换成数字的就用atoi等函数去转换,那么就不需要去考虑网络字节序的问题了,单个字节没有字节序的问题啦。
(理解:这就是为什么上面例子中要将端口3002的数字变成网络字节序,将127.0.0.1的ip地址通过inet_addr函数转化为网络字节序)

五、ifconfig命令

https://blog.csdn.net/l_liangkk/article/details/114959914

六、tcpdump使用

https://zhuanlan.zhihu.com/p/415723909
https://zhuanlan.zhihu.com/p/74812069
https://www.cnblogs.com/ggjucheng/archive/2012/01/14/2322659.html

1.命令行参数介绍:

-A 以ASCII格式打印出所有分组,并将链路层的头最小化。
-c 在收到指定的数量的分组后,tcpdump就会停止。
-C 在将一个原始分组写入文件之前,检查文件当前的大小是否超过了参数file_size中指定的大小。如果超过了指定大小,则关闭当前文件,然后在打开一个新的文件。参数 file_size的单位是兆字节(是1,000,000字节,而不是1,048,576字节)。
-d 将匹配信息包的代码以人们能够理解的汇编格式给出。
-dd 将匹配信息包的代码以c语言程序段的格式给出。
-ddd 将匹配信息包的代码以十进制的形式给出。
-D 打印出系统中所有可以用tcpdump截包的网络接口。
-e 在输出行打印出数据链路层的头部信息。
-E 用spi@ipaddr algo:secret解密那些以addr作为地址,并且包含了安全参数索引值spi的IPsec ESP分组。
-f 将外部的Internet地址以数字的形式打印出来。
-F 从指定的文件中读取表达式,忽略命令行中给出的表达式。
-i 指定监听的网络接口
-l 使标准输出变为缓冲行形式。
-L 列出网络接口的已知数据链路。
-m 从文件module中导入SMI MIB模块定义。该参数可以被使用多次,以导入多个MIB模块。
-M 如果tcp报文中存在TCP-MD5选项,则需要用secret作为共享的验证码用于验证TCP-MD5选选项摘要(详情可参考RFC 2385)。
-n 不把网络地址转换成名字。
-N 不输出主机名中的域名部分。例如,link.linux265.com 只输出link。
-t 在输出的每一行不打印时间戳。
-O 不运行分组分组匹配(packet-matching)代码优化程序。
-P 不将网络接口设置成混杂模式。
-q 快速输出。只输出较少的协议信息。
-r 从指定的文件中读取包(这些包一般通过-w选项产生)。
-S 将tcp的序列号以绝对值形式输出,而不是相对值。
-s 从每个分组中读取最开始的snaplen个字节,而不是默认的68个字节。
-T 将监听到的包直接解释为指定的类型的报文,常见的类型有rpc远程过程调用)和snmp(简单网络管理协议;)。
-t 不在每一行中输出时间戳。
-tt 在每一行中输出非格式化的时间戳。
-ttt 输出本行和前面一行之间的时间差。
-tttt 在每一行中输出由date处理的默认格式的时间戳。
-u 输出未解码的NFS句柄。
-v 输出一个稍微详细的信息,例如在ip包中可以包括ttl和服务类型的信息。
-vv 输出详细的报文信息。

-w 直接将分组写入文件中,而不是不分析并打印出来。
-x 以16进制数形式显示每一个报文 (去掉链路层报头) . 可以显示较小的完整报文, 否则只显示snaplen个字节.-xx 以16进制数形式显示每一个报文(包含链路层包头)。
-X 以16进制和ASCII码形式显示每个报文(去掉链路层报头)。
-XX 以16进制和ASCII吗形式显示每个报文(包含链路层报头)。
-y 设置tcpdump 捕获数据链路层协议类型
-Z 使tcpdump 放弃自己的超级权限(如果以root用户启动tcpdump, tcpdump将会有超级用户权限), 并把当前tcpdump的用户ID设置为user, 组ID设置为user首要所属组的ID

2.tcpdump 命令使用示例

01、抓取所有网络包,并在terminal中显示抓取的结果,将包以十六进制的形式显示。

tcpdump

02、抓取所有的网络包,并存到 result.cap 文件中。

tcpdump -w result.cap

03、抓取所有的经过eth0网卡的网络包,并存到result.cap 文件中。

tcpdump -i eth0 -w result.cap

04、抓取源地址是192.168.1.100的包,并将结果保存到 result.cap 文件中。

tcpdump src host 192.168.1.100 -w result.cap

05、抓取地址包含是192.168.1.100的包,并将结果保存到 result.cap 文件中。

tcpdump host 192.168.1.100 -w result.cap

06、抓取目的地址包含是192.168.1.100的包,并将结果保存到 result.cap 文件中。

tcpdump dest host 192.168.1.100 -w result.cap

07、抓取主机地址为 192.168.1.100 的数据包

tcpdump -i eth0 -vnn host 192.168.1.100

08、抓取包含192.168.1.0/24网段的数据包

tcpdump -i eth0 -vnn net 192.168.1.0/24

09、抓取网卡eth0上所有包含端口22的数据包

tcpdump -i eth0 -vnn port 22

10、抓取指定协议格式的数据包,协议格式可以是「udp,icmp,arp,ip」中的任何一种,例如以下命令:

tcpdump udp  -i eth0 -vnn

11、抓取经过 eth0 网卡的源 ip 是 192.168.1.100 数据包,src参数表示源。

tcpdump -i eth0 -vnn src host 192.168.1.100

12、抓取经过 eth0 网卡目的 ip 是 192.168.1.100 数据包,dst参数表示目的。

tcpdump -i eth0 -vnn dst host 192.168.1.100

13、抓取源端口是22的数据包

tcpdump -i eth0 -vnn src port 22

14、抓取源ip是 192.168.1.100 且目的ip端口是22的数据包

tcpdump -i eth0 -vnn src host 192.168.1.100 and dst port 22

15、抓取源ip192.168.1.10022

tcpdump -i eth0 -vnn src host 192.168.1.100 or port 22

16、抓取源ip192.168.1.10022

tcpdump -i eth0 -vnn src host 192.168.1.100 and not port 22

17、抓取源ip是192.168.1.100且目的端口是22,或源ip是192.168.1.102且目的端口是80的数据包。

tcpdump -i eth0 -vnn ( src host 192.168.1.100 and dst port 22 ) or ( src host 192.168.1.102 and dst port 80 )
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值