绑定完请刷新页面
取消
刷新

分享好友

×
取消 复制
PF_RING实现分析(2)
2020-05-22 16:39:22

4、mmap操作
用户态的接下来调用:

  1.                         ring->buffer = (char *)mmap(NULL, PAGE_SIZE, PROT_READ|PROT_WRITE,
  2.                                 MAP_SHARED, ring->fd, 0);
复制代码


进行内存映射。
同样地,内核调用相应的ring_mmap进行处理。
Ring选项结构通过ring_sk宏与sk 建立关联

  1. struct ring_opt *pfr = ring_sk(sk);
复制代码


pfr->ring_memory 即为分配的环形队列空间。所以,要mmap操作,实际上就是调用remap_pfn_range函数把pfr->ring_memory 映射到用户空间即可。这个函数的原型为:

  1. /**
  2. * remap_pfn_range - remap kernel memory to userspace
  3. * @vma: user vma to map to
  4. * @addr: target user address to start at
  5. * @pfn: physical address of kernel memory
  6. * @size: size of map area
  7. * @prot: page protection flags for this mapping
  8. *
  9. *  Note: this is only safe if the mm semaphore is held when called.
  10. */
  11. int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr,
  12.                     unsigned long pfn, unsigned long size, pgprot_t prot)
  13. {
复制代码


关于remap_pfn_range函数的进一步说明,可以参考LDD3,上面有详细说明和现成的例子。

  1. static int ring_mmap(struct file *file,
  2.                      struct socket *sock, struct vm_area_struct *vma)
  3. {
  4.   struct sock *sk = sock->sk;
  5.   struct ring_opt *pfr = ring_sk(sk);                //取得pfr指针,也就是相应取得环形队列的内存空间地址指针
  6.   int rc;
  7.   unsigned long size = (unsigned long)(vma->vm_end - vma->vm_start);

  8.   if(size % PAGE_SIZE) {
  9. #if defined(RING_DEBUG)
  10.     printk("[PF_RING] ring_mmap() failed: "
  11.            "len is not multiple of PAGE_SIZE\n");
  12. #endif
  13.     return(-EINVAL);
  14.   }
  15. #if defined(RING_DEBUG)
  16.   printk("[PF_RING] ring_mmap() called, size: %ld bytes\n", size);
  17. #endif

  18.   if((pfr->dna_device == NULL) && (pfr->ring_memory == NULL)) {
  19. #if defined(RING_DEBUG)
  20.     printk("[PF_RING] ring_mmap() failed: "
  21.            "mapping area to an unbound socket\n");
  22. #endif
  23.     return -EINVAL;
  24.   }

  25.   //dns设备为空,即没有使用dns技术
  26.   if(pfr->dna_device == NULL) {
  27.     /* if userspace tries to mmap beyond end of our buffer, fail */
  28.     //映射空间超限
  29.     if(size > pfr->slots_info->tot_mem) {
  30. #if defined(RING_DEBUG)
  31.       printk("[PF_RING] ring_mmap() failed: "
  32.              "area too large [%ld > %d]\n",
  33.              size, pfr->slots_info->tot_mem);
  34. #endif
  35.       return(-EINVAL);
  36.     }
  37. #if defined(RING_DEBUG)
  38.     printk("[PF_RING] mmap [slot_len=%d]"
  39.            "[tot_slots=%d] for ring on device %s\n",
  40.            pfr->slots_info->slot_len, pfr->slots_info->tot_slots,
  41.            pfr->ring_netdev->name);
  42. #endif
  43.         //进行内存映射
  44.     if((rc =
  45.          do_memory_mmap(vma, size, pfr->ring_memory, VM_LOCKED,
  46.                         0)) < 0)
  47.       return(rc);
  48.   } else {
  49.     /* DNA Device */
  50.     if(pfr->dna_device == NULL)
  51.       return(-EAGAIN);

  52.     switch (pfr->mmap_count) {
  53.     case 0:
  54.       if((rc = do_memory_mmap(vma, size,
  55.                                (void *)pfr->dna_device->
  56.                                packet_memory, VM_LOCKED,
  57.                                1)) < 0)
  58.         return(rc);
  59.       break;

  60.     case 1:
  61.       if((rc = do_memory_mmap(vma, size,
  62.                                (void *)pfr->dna_device->
  63.                                descr_packet_memory, VM_LOCKED,
  64.                                1)) < 0)
  65.         return(rc);
  66.       break;

  67.     case 2:
  68.       if((rc = do_memory_mmap(vma, size,
  69.                                (void *)pfr->dna_device->
  70.                                phys_card_memory,
  71.                                (VM_RESERVED | VM_IO), 2)) < 0)
  72.         return(rc);
  73.       break;

  74.     default:
  75.       return(-EAGAIN);
  76.     }

  77.     pfr->mmap_count++;
  78.   }

  79. #if defined(RING_DEBUG)
  80.   printk("[PF_RING] ring_mmap succeeded\n");
  81. #endif

  82.   return 0;
  83. }
复制代码



实际上的内存映射工作,是由do_memory_mmap来完成的,这个函数实际上基本就是remap_pfn_range的包裹函数。
不过因为系统支持dna等技术,相应的mode参数有些变化,这里只分析了基本的方法:mode == 0

  1. static int do_memory_mmap(struct vm_area_struct *vma,
  2.                           unsigned long size, char *ptr, u_int flags, int mode)
  3. {
  4.   unsigned long start;
  5.   unsigned long page;

  6.   /* we do not want to have this area swapped out, lock it */
  7.   vma->vm_flags |= flags;
  8.   start = vma->vm_start;

  9.   while (size > 0) {
  10.     int rc;

  11.     if(mode == 0) {
  12. #if(LINUX_VERSION_CODE >= KERNEL_VERSION(2,6,11))
  13.           //根据地址,计算要映射的页帧
  14.       page = vmalloc_to_pfn(ptr);
  15.       //进行内存映射
  16.       rc = remap_pfn_range(vma, start, page, PAGE_SIZE,
  17.                            PAGE_SHARED);
  18. #else
  19.       page = vmalloc_to_page(ptr);
  20.       page = kvirt_to_pa(ptr);
  21.       rc = remap_page_range(vma, start, page, PAGE_SIZE,
  22.                             PAGE_SHARED);
  23. #endif
  24.     } else if(mode == 1) {
  25.       rc = remap_pfn_range(vma, start,
  26.                            __pa(ptr) >> PAGE_SHIFT,
  27.                            PAGE_SIZE, PAGE_SHARED);
  28.     } else {
  29.       rc = remap_pfn_range(vma, start,
  30.                            ((unsigned long)ptr) >> PAGE_SHIFT,
  31.                            PAGE_SIZE, PAGE_SHARED);
  32.     }

  33.     if(rc) {
  34. #if defined(RING_DEBUG)
  35.       printk("[PF_RING] remap_pfn_range() failed\n");
  36. #endif
  37.       return(-EAGAIN);
  38.     }

  39.     start += PAGE_SIZE;
  40.     ptr += PAGE_SIZE;
  41.     if(size > PAGE_SIZE) {
  42.       size -= PAGE_SIZE;
  43.     } else {
  44.       size = 0;
  45.     }
  46.   }

  47.   return(0);
  48. }
复制代码



嗯,跳过了太多的细节,不过其mmap核心的东东已经呈现出来。
如果要共享内核与用户空间内存,这倒是个现成的可借鉴的例子。

5、数据包的入队操作

做到这一步,准备工作基本上就完成了。因为PF_RING在初始化中,注册了prot_hook。其func指针指向packet_rcv函数:
当数据报文进入Linux网络协议栈队列时,netif_receive_skb会遍历这些注册的Hook:

  1. int netif_receive_skb(struct sk_buff *skb)
  2. {
  3.         list_for_each_entry_rcu(ptype, &ptype_all, list) {
  4.                 if (ptype->dev == null_or_orig || ptype->dev == skb->dev ||
  5.                     ptype->dev == orig_dev) {
  6.                         if (pt_prev)
  7.                                 ret = deliver_skb(skb, pt_prev, orig_dev);
  8.                         pt_prev = ptype;
  9.                 }
  10.         }
  11. }
复制代码



相应的Hook函数得到调用:

  1. static inline int deliver_skb(struct sk_buff *skb,
  2.                               struct packet_type *pt_prev,
  3.                               struct net_device *orig_dev)
  4. {
  5.         atomic_inc(&skb->users);        //注意,这里引用计数器被增加了
  6.         return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
  7. }
复制代码



packet_rcv随之执行环形队列的入队操作:

  1. static int packet_rcv(struct sk_buff *skb, struct net_device *dev,
  2.                       struct packet_type *pt, struct net_device *orig_dev)
  3. {
  4.   int rc;

  5.   //忽略本地环回报文
  6.   if(skb->pkt_type != PACKET_LOOPBACK) {
  7.           //进一步转向,后一个参数直接使用-1,从上下文来看,写为RING_ANY_CHANNEL(其实也是-1)似乎可读性更强,
  8.           //这里表示,如果从packet_rcv进入队列,由通道ID是“未指定的”,由skb_ring_handler来处理
  9.     rc = skb_ring_handler(skb,
  10.                           (skb->pkt_type == PACKET_OUTGOING) ? 0 : 1,
  11.                           1, -1 /* unknown channel */);

  12.   } else
  13.     rc = 0;

  14.   kfree_skb(skb);                                //所以,这里要做相应的减少
  15.   return(rc);
  16. }
复制代码




static int skb_ring_handler(struct sk_buff *skb,                                //要捕获的数据包
                            u_char recv_packet,                                                                //数据流方向,>0表示是进入(接收)方向
                            u_char real_skb /* 1=real skb, 0=faked skb */ ,
                            short channel_id)                                                                //通道ID
{
  struct sock *skElement;
  int rc = 0, is_ip_pkt;
  struct list_head *ptr;
  struct pfring_pkthdr hdr;
  int displ;
  struct sk_buff *skk = NULL;
  struct sk_buff *orig_skb = skb;

#ifdef PROFILING
  uint64_t rdt = _rdtsc(), rdt1, rdt2;
#endif

  //skb合法检查,包括数据流的方向
  if((!skb)                /* Invalid skb */
      ||((!enable_tx_capture) && (!recv_packet))) {
    /*
      An outgoing packet is about to be sent out
      but we decided not to handle transmitted
      packets.
    */
    return(0);
  }
#if defined(RING_DEBUG)
  if(1) {
    struct timeval tv;

    skb_get_timestamp(skb, &tv);
    printk
      ("[PF_RING] skb_ring_handler() [skb=%p][%u.%u][len=%d][dev=%s][csum=%u]\n",
       skb, (unsigned int)tv.tv_sec, (unsigned int)tv.tv_usec,
       skb->len,
       skb->dev->name == NULL ? "<NULL>" : skb->dev->name,
       skb->csum);
  }
#endif

        //如果通道ID未指定,根据进入的报文设备索引,设定之
#if(LINUX_VERSION_CODE >= KERNEL_VERSION(2,6,21))
  if(channel_id == RING_ANY_CHANNEL /* Unknown channel */ )
    channel_id = skb->iif;        /* Might have been set by the driver */
#endif

#if defined (RING_DEBUG)
  /* printk("[PF_RING] channel_id=%d\n", channel_id); */
#endif

#ifdef PROFILING
  rdt1 = _rdtsc();
#endif

  if(recv_packet) {
    /* Hack for identifying a packet received by the e1000 */
    if(real_skb)
      displ = SKB_DISPLACEMENT;
    else
      displ = 0;        /* Received by the e1000 wrapper */
  } else
    displ = 0;
        
  //解析数据报文,并判断是否为IP报文
  is_ip_pkt = parse_pkt(skb, displ, &hdr);

  //分片处理,是一个可选的功能项,事实上,对大多数包捕获工具而言,它们好像都不使用底层库来完成这一功能
  /* (de)Fragmentation <fusco@ntop.org> */
  if(enable_ip_defrag
      && real_skb && is_ip_pkt && recv_packet && (ring_table_size > 0)) {
    } else {
#if defined (RING_DEBUG)
        printk("[PF_RING] Do not seems to be a fragmented ip_pkt[iphdr=%p]\n",
               iphdr);
#endif
      }
    }
  }

  //按惯例,在报文的捕获首部信息中记录捕获的时间戳
  /* BD - API changed for time keeping */
#if(LINUX_VERSION_CODE < KERNEL_VERSION(2,6,14))
  if(skb->stamp.tv_sec == 0)
    do_gettimeofday(&skb->stamp);
  hdr.ts.tv_sec = skb->stamp.tv_sec, hdr.ts.tv_usec = skb->stamp.tv_usec;
#elif(LINUX_VERSION_CODE < KERNEL_VERSION(2,6,22))
  if(skb->tstamp.off_sec == 0)
    __net_timestamp(skb);
  hdr.ts.tv_sec = skb->tstamp.off_sec, hdr.ts.tv_usec =
    skb->tstamp.off_usec;
#else /* 2.6.22 and above */
  if(skb->tstamp.tv64 == 0)
    __net_timestamp(skb);
  hdr.ts = ktime_to_timeval(skb->tstamp);
#endif

  //除了时间,还有长度,熟悉libpcap的话,这些操作应该很眼熟
  hdr.len = hdr.caplen = skb->len + displ;

  /* Avoid the ring to be manipulated while playing with it */
  read_lock_bh(&ring_mgmt_lock);

  /* 前面在创建sk时,已经看过ring_insert的入队操作了,现在要检查它的成员
  * 它们的关系是,通过ring_table的成员,获取到element,它里面封装了sk,
  *通过ring_sk宏,就可以得到ring_opt指针
   */
  list_for_each(ptr, &ring_table) {
    struct ring_opt *pfr;
    struct ring_element *entry;

    entry = list_entry(ptr, struct ring_element, list);

    skElement = entry->sk;
    pfr = ring_sk(skElement);
        
        //看来要加入社团,条件还是满多的,pfr不能为空,未指定集群cluster_id,槽位不能为空,方向要正确,绑定的网络设备
        //得对上号
        //另一种可能就是对bonding的支持,如果设备是从属设备,则应校验其主设备
    if((pfr != NULL)
        && (pfr->cluster_id == 0 /* No cluster */ )
        && (pfr->ring_slots != NULL)
        && is_valid_skb_direction(pfr->direction, recv_packet)
        && ((pfr->ring_netdev == skb->dev)
            || ((skb->dev->flags & IFF_SLAVE)
                && (pfr->ring_netdev == skb->dev->master)))) {
      /* We've found the ring where the packet can be stored */
      /* 从新计算捕获帧长度,是因为可能因为巨型帧的出现——超过了桶能容纳的长度 */
      int old_caplen = hdr.caplen;        /* Keep old lenght */
      hdr.caplen = min(hdr.caplen, pfr->bucket_len);
      /* 入队操作 */
      add_skb_to_ring(skb, pfr, &hdr, is_ip_pkt, displ, channel_id);
      hdr.caplen = old_caplen;
      rc = 1;        /* Ring found: we've done our job */
    }
  }

  /* [2] Check socket clusters */
  list_for_each(ptr, &ring_cluster_list) {
    ring_cluster_element *cluster_ptr;
    struct ring_opt *pfr;

    cluster_ptr = list_entry(ptr, ring_cluster_element, list);

    if(cluster_ptr->cluster.num_cluster_elements > 0) {
      u_int skb_hash = hash_pkt_cluster(cluster_ptr, &hdr);

      skElement = cluster_ptr->cluster.sk[skb_hash];

      if(skElement != NULL) {
        pfr = ring_sk(skElement);

        if((pfr != NULL)
            && (pfr->ring_slots != NULL)
            && ((pfr->ring_netdev == skb->dev)
                || ((skb->dev->flags & IFF_SLAVE)
                    && (pfr->ring_netdev ==
                        skb->dev->master)))
            && is_valid_skb_direction(pfr->direction, recv_packet)
            ) {
          /* We've found the ring where the packet can be stored */
          add_skb_to_ring(skb, pfr, &hdr,
                          is_ip_pkt, displ,
                          channel_id);
          rc = 1;        /* Ring found: we've done our job */
        }
      }
    }
  }

  read_unlock_bh(&ring_mgmt_lock);

#ifdef PROFILING
  rdt1 = _rdtsc() - rdt1;
#endif

#ifdef PROFILING
  rdt2 = _rdtsc();
#endif

  /* Fragment handling */
  if(skk != NULL)
    kfree_skb(skk);

  if(rc == 1) {
    if(transparent_mode != driver2pf_ring_non_transparent) {
      rc = 0;
    } else {
      if(recv_packet && real_skb) {
#if defined(RING_DEBUG)
        printk("[PF_RING] kfree_skb()\n");
#endif

        kfree_skb(orig_skb);
      }
    }
  }
#ifdef PROFILING
  rdt2 = _rdtsc() - rdt2;
  rdt = _rdtsc() - rdt;

#if defined(RING_DEBUG)
  printk
    ("[PF_RING] # cycles: %d [lock costed %d %d%%][free costed %d %d%%]\n",
     (int)rdt, rdt - rdt1,
     (int)((float)((rdt - rdt1) * 100) / (float)rdt), rdt2,
     (int)((float)(rdt2 * 100) / (float)rdt));
#endif
#endif

  //printk("[PF_RING] Returned %d\n", rc);
  return(rc);                /*  0 = packet not handled */
}

上面跳过了对cluster(集群)的分析,PF_RING允许同时对多个接口捕获报文,而并不是一个。这就是集群。看一下它用户态的注释就一目了然了:

  1.                         /* Syntax
  2.                         ethX@1,5       channel 1 and 5
  3.                         ethX@1-5       channel 1,2...5
  4.                         ethX@1-3,5-7   channel 1,2,3,5,6,7
  5.                         */
复制代码


进一步的入队操作,是通过add_skb_to_ring来完成的:

  1. static int add_skb_to_ring(struct sk_buff *skb,
  2.                            struct ring_opt *pfr,
  3.                            struct pfring_pkthdr *hdr,
  4.                            int is_ip_pkt, int displ, short channel_id)
  5. {
  6.       //add_skb_to_ring函数比较复杂,因为它要处理过滤器方面的问题。
  7.       //关于PF_RING的过滤器,可以参考[url]http://luca.ntop.org/Blooms.pdf[/url]
  8.       //获取更多内容。这里不做详细讨论了。或者留到下回分解吧。
  9.       
  10.       //终入队操作,是通过调用dd_pkt_to_ring来实现的。
  11.       add_pkt_to_ring(skb, pfr, hdr, displ, channel_id,
  12.                       offset, mem);        
  13. }
复制代码



  1. static void add_pkt_to_ring(struct sk_buff *skb,
  2.                             struct ring_opt *pfr,
  3.                             struct pfring_pkthdr *hdr,
  4.                             int displ, short channel_id,
  5.                             int offset, void *plugin_mem)
  6. {
  7.   char *ring_bucket;
  8.   int idx;
  9.   FlowSlot *theSlot;
  10.   int32_t the_bit = 1 << channel_id;

  11. #if defined(RING_DEBUG)
  12.   printk("[PF_RING] --> add_pkt_to_ring(len=%d) [pfr->channel_id=%d][channel_id=%d]\n",
  13.          hdr->len, pfr->channel_id, channel_id);
  14. #endif

  15.   //检查激活标志
  16.   if(!pfr->ring_active)
  17.     return;

  18.   if((pfr->channel_id != RING_ANY_CHANNEL)
  19.       && (channel_id != RING_ANY_CHANNEL)
  20.       && ((pfr->channel_id & the_bit) != the_bit))
  21.     return; /* Wrong channel */

  22.   //写锁
  23.   write_lock_bh(&pfr->ring_index_lock);
  24.   //获取前一次插入的位置索引
  25.   idx = pfr->slots_info->insert_idx;
  26.   //调用get_insert_slot获取当前要捕获数据报文的合适的槽位
  27.   //这里idx++后,指向了下一次插入的位置索引
  28.   idx++, theSlot = get_insert_slot(pfr);
  29.   //累计计数器
  30.   pfr->slots_info->tot_pkts++;

  31.   //没位子了,累计丢包计数器,返回之
  32.   if((theSlot == NULL) || (theSlot->slot_state != 0)) {
  33.     /* No room left */
  34.     pfr->slots_info->tot_lost++;
  35.     write_unlock_bh(&pfr->ring_index_lock);
  36.     return;
  37.   }

  38.   //获取当前槽位的桶
  39.   ring_bucket = &theSlot->bucket;

  40.   //支持插件??在开始处记录插件信息??
  41.   if((plugin_mem != NULL) && (offset > 0))
  42.     memcpy(&ring_bucket[sizeof(struct pfring_pkthdr)], plugin_mem, offset);  

  43.   if(skb != NULL) {
  44.           //重新计算捕获帧长度
  45.     hdr->caplen = min(pfr->bucket_len - offset, hdr->caplen);

  46.     if(hdr->caplen > 0) {
  47. #if defined(RING_DEBUG)
  48.       printk("[PF_RING] --> [caplen=%d][len=%d][displ=%d][parsed_header_len=%d][bucket_len=%d][sizeof=%d]\n",
  49.          hdr->caplen, hdr->len, displ,
  50.              hdr->parsed_header_len, pfr->bucket_len,
  51.              sizeof(struct pfring_pkthdr));
  52. #endif
  53.       //拷贝捕获的数据报文,前面空了两个栏位:一个是pkthdr首部,一个是插件offset长度
  54.       //这里经过了一次数据拷贝,对于完美主义者,这并不是一个好的方法。但是PF_RING定位于一个
  55.       //通用的接口库,似乎只有这么做了。否则,追求“零拷贝”,为了避免这一次拷贝,只有逐个修改网卡驱动了。
  56.       skb_copy_bits(skb, -displ,
  57.                     &ring_bucket[sizeof(struct pfring_pkthdr) + offset], hdr->caplen);
  58.     } else {
  59.       if(hdr->parsed_header_len >= pfr->bucket_len) {
  60.         static u_char print_once = 0;

  61.         if(!print_once) {
  62.           printk("[PF_RING] WARNING: the bucket len is [%d] shorter than the plugin parsed header [%d]\n",
  63.              pfr->bucket_len, hdr->parsed_header_len);
  64.           print_once = 1;
  65.         }
  66.       }
  67.     }
  68.   }

  69.   //记录首部
  70.   memcpy(ring_bucket, hdr, sizeof(struct pfring_pkthdr)); /* Copy extended packet header */

  71.   //前面idx已经自加过了,判断是否队列已满,若满,归零,否则更新插入索引
  72.   if(idx == pfr->slots_info->tot_slots)
  73.     pfr->slots_info->insert_idx = 0;
  74.   else
  75.     pfr->slots_info->insert_idx = idx;

  76. #if defined(RING_DEBUG)
  77.   printk("[PF_RING] ==> insert_idx=%d\n", pfr->slots_info->insert_idx);
  78. #endif

  79.   //累计插入计数器
  80.   pfr->slots_info->tot_insert++;
  81.   //槽位就绪标记,用户空间可以来取了
  82.   theSlot->slot_state = 1;
  83.   write_unlock_bh(&pfr->ring_index_lock);

  84.   //有的时候会出现,用户空间取不到的情况,如队列为空。这样,用户空间调用poll等待数据。这里做相应的唤醒处理
  85.   /* wakeup in case of poll() */
  86.   if(waitqueue_active(&pfr->ring_slots_waitqueue))
  87.     wake_up_interruptible(&pfr->ring_slots_waitqueue);
  88. }
复制代码



槽位的计算:

  1. 在ring_bind函数中,分配空间后,使用ring_slots做为槽位指针。事实上,这里要计算槽位,就是通过索引号 * 槽位长度来得到:
  2. static inline FlowSlot *get_insert_slot(struct ring_opt *pfr)
  3. {
  4.   if(pfr->ring_slots != NULL) {
  5.     FlowSlot *slot =
  6.       (FlowSlot *) & (pfr->
  7.                       ring_slots[pfr->slots_info->insert_idx *
  8.                                  pfr->slots_info->slot_len]);
  9. #if defined(RING_DEBUG)
  10.     printk
  11.       ("[PF_RING] get_insert_slot(%d): returned slot [slot_state=%d]\n",
  12.        pfr->slots_info->insert_idx, slot->slot_state);
  13. #endif
  14.     return(slot);
  15.   } else {
  16. #if defined(RING_DEBUG)
  17.     printk("[PF_RING] get_insert_slot(%d): NULL slot\n",
  18.            pfr->slots_info->insert_idx);
  19. #endif
  20.     return(NULL);
  21.   }
  1. }
分享好友

分享这个小栈给你的朋友们,一起进步吧。

内核源码
创建时间:2020-05-18 13:36:55
内核源码精华帖内容汇总
展开
订阅须知

• 所有用户可根据关注领域订阅专区或所有专区

• 付费订阅:虚拟交易,一经交易不退款;若特殊情况,可3日内客服咨询

• 专区发布评论属默认订阅所评论专区(除付费小栈外)

技术专家

查看更多
  • 飘絮絮絮丶
    专家
戳我,来吐槽~