DIY TCP/IP TCP模块的实现10

上一篇:DIY TCP/IP TCP模块的实现9
9.12 TCP滑动窗口的实现4
本节在9.11节的基础上,实现9.9节介绍的TCP滑动窗口算法。DIY TCP/IP在TCP连接建立之后,接收到TCP Client发送的数据帧时,将收到的TCP数据帧携带的数据放入滑动窗口中。正确更新滑动窗口数据结构中的”指针”位置,根据滑动窗口的指针位置计算接收缓存中剩余的空间大小,即本地TCP连接的window size。正确构造TCP ACK数据帧,并回复确认给TCP Client。通知模拟的读取线程,从滑动窗口中提取确认过的数据,使滑动窗口能够正确的循环”向右”滑动。
9.9节介绍的滑动窗口算法可以分成四个部分实现:向滑动窗口存放数据并更新recv_end;从滑动窗口提取数据并更新ack_start;回复TCP ACK后更新滑动窗口的ack_end;计算滑动窗口中剩余的空间大小。
先来看向滑动窗口中存放数据并更新recv_end的函数sw_push_data。该函数是TCP模块的静态函数,定义在tcp.c文件中。函数的入参:sw指向滑动窗口的指针,data指向需要放入滑动窗口的数据的指针,data_len是需要放入滑动窗口的数据长度。成功返回0,失败返回非0。

 /* copy data to sliding window */
 static int sw_push_data(tcp_slide_window_t *sw,
        unsigned char *data, unsigned int len)
 {
         unsigned int recv_end = 0;
         unsigned int copy_sz = 0;
        unsigned int left_buf_sz = 0;
 
         if (sw == NULL || data == NULL )
                 return -1;
 
        left_buf_sz = sw_left_buf_sz(sw);
        if (len == 0 || left_buf_sz < len)
            return -1;
 
         recv_end = sw->recv_end;
         copy_sz = len;
         /* len exceeded buf end offset */
         if (recv_end + len > sw->buf_sz)
                 copy_sz = sw->buf_sz - sw->recv_end;
         assert(copy_sz <= len);
         memcpy(&sw->buf[recv_end], data, copy_sz);
 
         recv_end += copy_sz;
         /* update recv_end */
         sw->recv_end = recv_end;
         if (copy_sz == len) {
                 return 0;
         }
 
         assert(recv_end == sw->buf_sz);
         /* copy left data to the start of buffer
         * max(recv_end) <= acked_start, this is checked in tcp_recv_buf_sz
         */
         recv_end = sw->buf_start;
         data += copy_sz;
         copy_sz = len - copy_sz;
         /* max(recv_end) <= acked_start */
         assert((recv_end + copy_sz) <= sw->acked_start);
         memcpy(&sw->buf[recv_end], data, copy_sz);
         /* update recv_end */
         recv_end += copy_sz;
         sw->recv_end = recv_end;
         return 0;
 }

Line 5-31: 数据存入滑动窗口,从recv_end开始存放,首先调用sw_left_buf_sz计算滑动窗口中剩余的内存空间大小left_buf_sz(蓝色部分)。left_buf_sz大于等于待拷贝的数据长度时,继续执行。如果recv_end + len没有超出buf_sz,则直接拷贝数据到recv_end,然后更新recv_end到recv_end + len,函数返回。如果recv_end + len超出buf_sz,将数据分成两个部分(黄色部分),第一部分的长度copy_sz = buf_sz – recv_end,第二部分长度left_to_copy=len – copy_sz。将第一部分数据拷贝到recv_end开始的内存中,更新recv_end。第一部分数据拷贝完成后,recv_end指向接收缓存末尾字节的下一个字节(copy_sz表示的黄色部分的末尾)。第二部分待拷贝数据(left_to_copy表示的黄色部分),需要回绕到接收缓存开始buf_start处存放。
DIY TCP/IP push data to sw step1
Line 32 – 44: 将剩余数据放入接收缓存之前,先计算接收缓存中可用的内存空间大小acked_start – buffer_start,如果剩余的内存空间大于left_to_copy,则将剩余数据存放到接收缓存buffer_start指向的内存中。
DIY TCP/IP push data to sw step2 & step3
最后更新recv_end到buffer_start + left_to_copy,函数返回。
再来看sw_push_data函数中用到的sw_left_buf_sz的实现。该函数同样是tcp.c文件中的静态函数,入参是指向滑动窗口的指针,返回值是滑动窗口中剩余的内存大小,即9.9节介绍的滑动窗口结构图中蓝色部分的大小。

 static unsigned int sw_left_buf_sz(tcp_slide_window_t *sw)
 {
    unsigned int acked_sz = 0;
    unsigned int unacked_sz = 0;
 
    if (sw == NULL)
        return 0;
    
    acked_sz = sw_acked_data_sz(sw);
    unacked_sz = sw_unacked_data_sz(sw);
    
    return (sw->buf_sz - acked_sz - unacked_sz);
 }

sw_left_buf_sz调用sw_acked_data_dz,计算已经ack过的数据长度acked_sz,对应9.9节滑动窗口结构图中的红色部分,sw_unacked_data_sz计算已经接收但是尚未ack的数据unacked_sz,对应9.9节滑动窗口结构图中的绿色部分。接收缓存大小buf_sz减去acked_sz和unacked_sz后,返回剩余内存大小。

 static unsigned int sw_acked_data_sz(tcp_slide_window_t *sw)
 {
         unsigned int acked_end = 0;
         unsigned int acked_start = 0;
 
         if (sw == NULL)
                 return 0;
 
         acked_start = sw->acked_start;
         acked_end = sw->acked_end;
         if (acked_end < acked_start)
                 acked_end += sw->buf_sz;
 
         return acked_end - acked_start;
 }

DIY TCP/IP SW acked size
sw_ack_data_sz返回滑动窗口中已经接收过并且也回复了TCP ACK的数据,对应图中的红色部分。如果acked_end > acked_start,则红色部分的大小为acked_end – acked_start,如果acked_end < acked_start,则说明发生了回绕,acked_end + buf_end – acked_start即红色部分的大小。

 static unsigned int sw_unacked_data_sz(tcp_slide_window_t *sw)
 {
         unsigned int recv_end = 0;
         unsigned int recv_start = 0;
 
         if (sw == NULL)
                 return 0;
 
         recv_start = sw->recv_start;
         recv_end = sw->recv_end;
         if (recv_end < recv_start)
                 recv_end += sw->buf_sz;
 
         return recv_end - recv_start;
 }

DIY TCP/IP SW unacked size
sw_ack_data_sz返回滑动窗口中已经接收但尚未回复TCP ACK的数据,对应图中的绿色部分。如果received end > received start,则绿色部分的大小为received end – received start,如果received end < received start,则说明发生了回绕,received end + buf_end – received end即绿色部分的大小。
再来看从滑动窗口中提取数据并更新acked_start的函数实现。sw_pull_data是定义在tcp.c文件中的静态函数,入参sw为指向滑动窗口的指针,buf指向存放提取数据的内存空间,buf_sz是存放提取数据的内存空间的大小。

 static int sw_pull_data(tcp_slide_window_t *sw,
        unsigned char *buf, unsigned int buf_sz)
 {
     unsigned int acked_sz = 0;
     unsigned int acked_start = 0;
     unsigned int copy_sz = 0;
 
     if (sw == NULL || buf == NULL)
         return  -1;
 
    acked_sz = sw_acked_data_sz(sw);
 
    if (acked_sz == 0) {
        log_printf(ERROR, "No data available\n");
        return -1;
    }
 
    if (acked_sz < buf_sz)
        buf_sz = acked_sz;
 
    acked_start = sw->acked_start;
    copy_sz = buf_sz;
    if (acked_start + copy_sz > sw->buf_sz)
        copy_sz = sw->buf_sz - acked_start;
     memcpy(buf, &sw->buf[acked_start], copy_sz);
 
     acked_start += copy_sz;
    /* update acked_start */
    sw->acked_start = acked_start;
    if (copy_sz == buf_sz)
        return 0;
    assert(sw->acked_start == sw->buf_sz);
    acked_start = sw->buf_start;
    buf += copy_sz;
    copy_sz = buf_sz - copy_sz;
    /* wrap around and copy left */
    assert(acked_start + copy_sz <= sw->acked_end);
    memcpy(buf, &sw->buf[acked_start], copy_sz);
    acked_start += copy_sz;
    /* updated acked_start */
    sw->acked_start = acked_start;
 
     log_printf(VERBOSE, "pull %u data\n", buf_sz);
 }

Line 4-31:调用sw_acked_data_sz计算滑动窗口中已经接收并且回复过TCP ACK的数据acked_sz(红色部分)。acked_sz大于等于待提取数据的长度时,继续执行。如果acked_start + buf_sz没有超出buf_end,则直接拷贝数据到buf,然后更新acked_start到acked_start + buf_sz,函数返回。如果acked_start + buf_sz超出buf_end,将数据分成两个部分提取,第一部分的长度copy_sz = buf_end – acked_start,第二部分长度left_to_copy=buf_sz – copy_sz。将第一部分数据提取到buf指向的内存中,更新acked_start。第一部分数据提取完成后,acked_start指向接收缓存末尾字节的下一个字节。第二部分待提取数据(left_to_copy表示的白色部分),需要回绕到接收缓存开始buf_start处提取。
DIY TCP/IP Pull Data from SW
Line 32-44: 提取剩余数据left_to_copy之前,再次计算接收缓存中可被提取的数据大小acked_end – buffer_start,如果可被提取的数据大小大于等于left_to_copy,则将数据提取到buf指向的内存中,buf在第一部分提取完成后已经指向了下一个待提取数据的字节地址处。
DIY TCP/IP SW pull data step2
最后更新acked_start到buf_start + left_to_copy,函数返回。
DIY TCP/IP SW pull data step3
再来看回复TCP ACK后,根据接收到的数据长度,更新滑动窗口的acked_end的实现。

 static void sw_ack_data(tcp_slide_window_t *sw, unsigned int len)
 {
         if (sw == NULL || len == 0)
                 return;
         sw->acked_end += len;
         if (sw->acked_end > sw->buf_sz)
                 sw->acked_end -= sw->buf_sz;
         assert(sw->acked_end <= sw->recv_end);
         sw->recv_start = sw->acked_end;
 }

sw_ack_data的第一个入参是指向滑动窗口的指针,第二个入参是接收到的TCP数据帧携带的数据部分的长度。如果acked_end + len大于buf_end,需要回绕acked_end,将acked_end更新到buf_end – (acked_end + len),检查acked_end的位置是否小于等于recv_end, 如果是,则更新recv_start到acked_end。
滑动窗口四个部分的算法实现sw_push_data,sw_pull_data,sw_left_buf_sz和sw_ack_data已经实现完成。接下来是TCP模块接收到TCP数据帧时,通过sw_push_data将TCP数据帧携带的数据放入滑动窗口,构建TCP ACK时,调用sw_left_buf_sz计算接收缓存中可用的内存空间,填入TCP ACK头部的window size字段。回复TCP ACK后调用sw_ack_data更新滑动窗口的acked_end,并通知模拟的读取线程调用sw_pull_data读取滑动窗口中的数据。
先来看tcppkt_recv函数的修改

  static int tcp_process_data_pkt(tcp_conn_t *conn,
                  tcphdr_t *tcppkt, unsigned short tcppkt_len)
  {
…
          pdbuf_push(pdbuf, tcpack_len);
          tcpack = (tcphdr_t *)pdbuf->payload;
          tcpack_flags.b.ack = 1;
          tcpack_flags.b.hdr_len = (tcpack_len >> 2);
          build_tcp_header(conn, tcpack, 0, tcpack_flags.v);
…
          ret = tcppkt_send(conn, pdbuf);
          if (ret == 0)
              tcp_input_data(conn, tcppkt, tcppkt_len);
          return ret;
  out:
          if (pdbuf)
                  pdbuf_free(pdbuf);
          return ret;
  }

高亮显示tcp_process_data_pkt在本节的改动,tcp_process_data_pkt其余部分的代码与9.10节一致。TCP连接建立之后,tcp_process_data_pkt处理接收到的TCP数据帧,构建TCP ACK,build_tcp_header中填入接收缓存剩余空间大小,tcppkt_send回复TCP ACK之后,调用tcp_input_data将TCP数据放入滑动窗口,并通知模拟读取线程读取数据。

  static int build_tcp_header(tcp_conn_t *conn,
                  tcphdr_t *tcphdr, unsigned short data_len,
                  unsigned short hflags)
  {
          int ret = 0;
          unsigned int left_buf_sz = 0;
          if (conn == NULL || tcphdr == NULL)
                  return -1;
          tcphdr->src_port = HSTON(conn->l_port);
          tcphdr->dst_port = HSTON(conn->r_port);
          tcphdr->seq_num = HLTON(conn->send_seq);
          tcphdr->ack_num = HLTON(conn->ack_seq);
          tcphdr->flags_len = HSTON(hflags);
          left_buf_sz = tcp_recv_wnd_sz(conn);
          tcphdr->wnd_sz = HSTON(left_buf_sz);
          tcphdr->cksum = 0;
          tcphdr->ugtp = 0;
          return ret;
  }

tcp_recv_wnd_sz是对sw_left_buf_sz的封装,由于DIY TCP/IP的TCP模块和模拟的读取线程都需要读取和修改滑动窗口的”指针”。在封装函数tcp_recv_wnd_sz中,首先获得保护滑动窗口的互斥量,再调用sw_left_buf_sz读取滑动窗口数据结构中的数据。

 static unsigned int tcp_recv_wnd_sz(tcp_conn_t *conn)
 {
    tcp_slide_window_t *sw = NULL;
    unsigned int left_buf_sz = 0;
    if (conn == NULL)
        return 0;
    sw = &conn->sw;
    pthread_mutex_lock(&sw->data_available_mutex);
    left_buf_sz = sw_left_buf_sz(sw);
    pthread_mutex_unlock(&sw->data_available_mutex);
    return left_buf_sz;
 }

tcp_process_data_pkt中调用的tcp_input_data也是对sw_push_data的封装。tcp_input_data的第一个参数是指向TCP连接的指针,第二个参数是接收到的TCP数据帧的指针,第三个参数是TCP数据帧的长度。

 static int tcp_input_data(tcp_conn_t *conn,
        tcphdr_t *tcppkt, unsigned short tcppkt_len)
 {
    int ret = 0;
    tcphdr_flags_t flags;
    unsigned int tcphdr_len = 0;
    unsigned int data_len = 0;
    unsigned char *data = NULL;
    tcp_slide_window_t *sw = NULL;
    
    if (conn == NULL || tcppkt == NULL || tcppkt_len ==0 )
            return -1;
    flags.v = NTOHS(tcppkt->flags_len);
    tcphdr_len = flags.b.hdr_len << 2;
    data_len = tcppkt_len - tcphdr_len;
    if (data_len == 0)
        return 0;
    sw = &conn->sw;
    data = (unsigned char *)tcppkt + tcphdr_len;
    pthread_mutex_lock(&sw->data_available_mutex);
    ret = sw_push_data(sw, data, data_len);
    pthread_mutex_unlock(&sw->data_available_mutex);
    tcp_notify_data_available(sw, data_len);
    dump_slide_window(conn);
    return ret;
 }

解析TCP数据帧头部,计算TCP数据帧携带的数据长度data_len,获取保护滑动窗口的互斥量data_available_mutex。调用sw_push_data将TCP数据存入滑动窗口中。tcp_input_data在tcp_process_data_pkt函数中的调用是在回复了TCP ACK之后,所以再调用tcp_notify_data_available通知读取线程。

 static void tcp_notify_data_available(tcp_slide_window_t *sw,
                unsigned int len)
 {
    pthread_mutex_lock(&sw->data_available_mutex);
    sw_ack_data(sw, len);
    pthread_mutex_unlock(&sw->data_available_mutex);
    /* indicate data available */
    pthread_cond_signal(&sw->data_available_cond);
 }

传入tcp_notify_data_avaliable的第二个参数len是TCP数据帧携带的数据部分的长度,获取data_available_mutex互斥量后,调用sw_ack_data更新滑动窗口的acked_end和recv_start指针,再唤醒读取线程读取数据。
最后再修改读取线程的主循环体simulate_read_routine函数

  static void * simulate_read_routine(void *arg)
  {
  #define EXPECT_SZ 1460 * 16
          unsigned char *data_buf = NULL;
          tcp_slide_window_t *sw = NULL;
  
          if (arg == NULL)
                  return NULL;
  
          sw = (tcp_slide_window_t *)arg;
          data_buf = (unsigned char *)malloc(EXPECT_SZ);
          if (data_buf == NULL) {
                  log_printf(DEBUG, "No memory "
             "for user data, %s (%d)\n",
             strerror(errno), errno);
                  return NULL;
          }
  
          log_printf(DEBUG, "User simulator started\n");
          while(sw->stop_simulator != 1) {
                  pthread_mutex_lock(&sw->data_available_mutex);
                  pthread_cond_wait(&sw->data_available_cond,
                     &sw->data_available_mutex);
                  pthread_mutex_unlock(&sw->data_available_mutex);
  
                  if (sw->stop_simulator)
                          break;
                  memset(data_buf, 0, EXPECT_SZ);
                  tcp_output_data(sw, data_buf, EXPECT_SZ);
                  printf("%s\n", data_buf);
          }
  
          if (data_buf)
                  free(data_buf);
          log_printf(DEBUG, "User simulator stopped\n");
          return NULL;
  }

相比9.11节simulate_read_routine的实现,本节只是添加了一个函数调用tcp_output_data。读取线程被唤醒后,判断退出条件不成立时,调用tcp_output_data从滑动窗口中读取数据,将读取到的数据已字符形式打印输出。

 static int tcp_output_data(tcp_slide_window_t *sw,
        unsigned char *buf, unsigned int buf_sz)
 {
    int ret = 0;
    if (sw == NULL || buf == NULL || buf_sz == 0)
        return -1;
    pthread_mutex_lock(&sw->data_available_mutex);
    ret = sw_pull_data(sw, buf, buf_sz);
    pthread_mutex_unlock(&sw->data_available_mutex);
    return ret;
 }

tcp_output_data是对sw_pull_data函数的封装,参数与sw_pull_data一致。获取到保护滑动窗口的互斥量后,调用sw_pull_data读取滑动窗口中的数据。
本节测试通过两个侧面验证滑动窗口的算法实现,第一个侧面是ipert TCP发送大量数据,验证滑动窗口可以接收数据,并处理回绕的情况,如果DIY TCP/IP能够长时间运行,不发生assert错误,则说明滑动窗口”指针”更新是正确的,大数据量情况下不容易确定接收到的数据的正确性,所以第二个侧面是,写一个简单的TCP Client程序,向DIY TCP/IP建立TCP连接,并发送字符串。如果读取线程能够正确接收TCP Cient发出的字符,则说明滑动窗口提取的数据是正确的。
先来看第二个侧面的测试结果。
TCP Client 测试代码用于向指定的TCP server建立TCP连接,并通过标准输入向TCP Server发送数据。
Line 10-22: TCP client有3个运行参数,arg0 是./client,arg1是IP address, arg2是port。检查运行参数个数合法后,通过socket系统调用,创建套接字,domain为AF_INET (IPv4 inernet protocols),type为SOCK_STREAM,面向连接的全双工字节流类型,protocol是默认值0。
Line 24-34: 设置TCP server的IP地址和端口号,通过connect系统调用与TCP Server建立TCP连接。
Line 35-48: TCP连接建立后,通过循环读取标准输入,read系统调用的第一个参数0,是进程的标准输入文件描述符。再将读取到的数据通过write系统调用发送到TCP server。循环的结束条件是read在标准输入中读到EOF,即终端键入ctrl+d。

 #include<stdio.h>
 #include<sys/types.h>
 #include<sys/socket.h>
 #include<unistd.h>
 #include<stdlib.h>
 #include<netinet/in.h>
 #include<arpa/inet.h>
 int main(int argc,const char* argv[])
 {
     if(argc != 3)
     {
         printf("Usage:%s [ip] [port]\n",argv[0]);
         return 0;
     }
 
     //create TCP socket
     int sock = socket(AF_INET,SOCK_STREAM, 0);
     if(sock < 0)
     {
         perror("socket");
         return 1;
     }
 
     //set TCP server IP address and port number
     struct sockaddr_in server;
     server.sin_family = AF_INET;
     server.sin_port = htons(atoi(argv[2]));
     server.sin_addr.s_addr = inet_addr(argv[1]);
     socklen_t len = sizeof(struct sockaddr_in);
     if(connect(sock, (struct sockaddr*)&server, len) < 0 )
     {
         perror("connect");
         return 2;
     }
     //connected to TCP server to send data
     char buf[1024];
     while(1)
     {
         printf("send###");
         fflush(stdout);
         ssize_t _s = read(0, buf, sizeof(buf)-1);
     if (_s == 0)
         break;
         buf[_s] = 0;
         write(sock, buf, _s);
     }
     close(sock);
     return 0;
 }

TCP Clinet通过GCC编译,命令为gcc -o client client.c。DIY TCP/IP运行在主机A上,client运行在主机D上,主机D与主机A处于同一局域网。DIY TCP/IP模拟TCP Server,指定局域网中不存在的IP地址192.168.1.7,端口号为7000。
Client运行时指定IP地址为192.168.1.7, 端口号为7000。先看主机D上Client的运行结果: client可以和DIY User Space TCP/IP建立TCP连接,通过标准输入键入”tcp hello world”发送给DIY TCP/IP。
DIY TCP/IP TCP Client
主机A上DIY User Sapce TCP/IP的运行结果:
DIY TCP/IP tcp hello world
DIY TCP/IP首先收到主机D发出的ARP Request,回复ARP Reply建立虚拟IP地址192.168.1.7和主机A上eth0接口硬件地址的映射。主机D发出TCP SYN触发TCP三步握手,DIY TCP/IP正确接收TCP SYN并回复TCP SYN-ACK后,收到主机D发出的TCP ACK,完成TCP三步握手,建立TCP连接。
主机D上的client发送TCP 数据帧,数据长度为16,与主机D上终端输入的”tcp hello wolrd”的长度一致。DIY TCP/IP接收TCP 数据帧,构建TCP ACK,TCP ACK头部的window size并没有改变,回顾tcp_process_data_pkt的代码实现,先回复TCP ACK,再将数据部分存入滑动窗口,并更新recv_end到16,所以build_tcp_header时的接收缓存大小没有变化。回复TCP ACK后更新acked end和recv start到16,并通知模拟读取线程从滑动窗口中读取数据,dump_slide_window打印输入可以看到滑动窗口”指针”的移动是正确的,模拟读取线程的输出为”tcp hello world”,表明DIY TCP/IP可以正确接收TCP数据帧并提取数据部分到接收缓存中。
第一个侧面的测试结果
DIY TCP/IP运行在主机A上,主机C(Android手机)与主机A处于同一局域网,运行iperf TCP client。指定DIY TCP/IP的IP地址为局域网中不存在的IP地址192.168.1.7,TCP端口号为7000。主机C通过iperf与DIY TCP/IP进行吞入量测试,通过DIY TCP/IP的打印输出,查看滑动窗口指针在回绕的情况处理是否正确。
DIY TCP/IP Iperf test
DIY TCP/IP打印输出太多,截取其中一个片段来分析,当滑动窗口的recv_end更新到16776488时,有接收到了长度为1448的TCP 数据,同样是先构建TCP ACK更新acked_end和recv_start,将1448的数据放入滑动窗口更新recv_end,这三个指针均发生回绕,回绕的位置为1448 - (16776960 - 16776488)。dump_slide_window的打印输入是符合预期的。
DIY TCP/IP Iperf test result
主机C端iperf的吞吐量为3 ~ 5Mbps,由于现阶段滑动窗口的存入和提取都需要拷贝数据,后续章节对滑动窗口实现优化时,提升吞吐量。
本章小结
本章介绍了TCP数据帧的头部结构,TCP选项字段,实现了TCP数据帧的接收,TCP伪头部校验和的检查和计算。实现了简单的TCP连接状态机,和简化的TCP滑动窗口,测试结果表明DIY TCP/IP已经可以正常与别的TCP client通信,并能支持局域网内的iperf 吞吐量测试。后续章节将专注于TCP滑动窗口的性能提升,扩展DIY TCP/IP的库函数接口,将DIY TCP/IP编译成动态库,供其他进应用程序使用。
目录结构
DIY TCP/IP Source Code Struct
下一阶段:DIY TCP/IP的扩展和性能优化

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值