KGTP中增加对GDB命令“set trace-buffer-size”的支持 - Week 7

CSDN开源夏令营 - 第七周工作总结


这一周在实现了“set trace-buffer-size”的simple mode(即舍弃Ring Buffer中原有的trace frames)基础之上,试图完成normal mode,因为用户有的时候是在trace过程中发现buffer不够大,此时暂停trace,待扩大buffer空间后继续trace,如果此时再使用simple mode,显然不符合用户的预期了。因此,normal mode需要处理用户原有trace frames的数据搬移,分配一套新的Ring Buffer后,拷贝原有Ring Buffer的数据过去,然后再把旧的Ring Buffer释放。如果用户把buffer size设小了,则需要考虑舍弃一部分数据,这个策略也要根据用户需求进行设计。

normal mode目前没有实现,这周主要分析下gtp_rb.c的实现,以及KGTP中涉及到的Linux Kernel编程的知识点吧。

1. SMP、NUMA、MPP体系结构介绍和对比

为啥会想着去看这些东西呢?因为KGTP的代码中充斥着per_cpu,这显然跟SMP相关了,然后再一扩展,就引出了SMP、NUMA、MPP三种体系结构了。

这三者的全称分别是,对称多处理器结构 (SMP : Symmetric Multi-Processor) ,非一致存储访问结构 (NUMA : Non-Uniform Memory Access) ,以及海量并行处理结构 (MPP : Massive Parallel Processing) ,下面说一下他们的basic idea。

SMP,所谓对称多处理器结构,是指服务器中多个 CPU 对称工作,无主次或从属关系。所有的CPU共享全部资源,如总线,内存和I/O系统等,操作系统或管理数据库的复本只有一个,这种系统有一个最大的特点就是共享所有资源。多个CPU之间没有区别,平等地访问内存、外设、一个操作系统。各 CPU 共享相同的物理内存,每个 CPU 访问内存中的任何地址所需时间是相同的,因此 SMP 也被称为一致存储器访问结构 (UMA : Uniform Memory Access) 。SMP 服务器的主要特征是共享,系统中所有资源 (CPU 、内存、 I/O 等 ) 都是共享的。也正是由于这种特征,导致了 SMP 服务器的主要问题,那就是它的扩展能力非常有限。对 SMP 服务器进行扩展的方式包括增加内存、使用更快的 CPU 、增加 CPU 、扩充 I/O( 槽口数与总线数 ) 以及添加更多的外部设备 ( 通常是磁盘存储 ) 。

NUMA,由于 SMP 在扩展能力上的限制,人们开始探究如何进行有效地扩展从而构建大型系统的技术, NUMA 就是这种努力下的结果之一。NUMA 服务器的基本特征是具有多个 CPU 模块,每个 CPU 模块由多个 CPU( 如 4 个 ) 组成,并且具有独立的本地内存、 I/O 槽口等。由于其节点之间可以通过互联模块 ( 如称为 Crossbar Switch) 进行连接和信息交互,因此每个 CPU 可以访问整个系统的内存 ( 这是 NUMA 系统与 MPP 系统的重要差别 ) 。显然,访问本地内存的速度将远远高于访问远地内存 ( 系统内其它节点的内存 ) 的速度,这也是非一致存储访问 NUMA 的由来。由于这个特点,为了更好地发挥系统性能,开发应用程序时需要尽量减少不同 CPU 模块之间的信息交互。但 NUMA 技术同样有一定缺陷,由于访问远地内存的延时远远超过本地内存,因此当 CPU 数量增加时,系统性能无法线性增加。

MPP,和 NUMA 不同, MPP 提供了另外一种进行系统扩展的方式,它由多个 SMP 服务器通过一定的节点互联网络进行连接,协同工作,完成相同的任务,从用户的角度来看是一个服务器系统。其基本特征是由多个 SMP 服务器 ( 每个 SMP 服务器称节点 ) 通过节点互联网络连接而成,每个节点只访问自己的本地资源 ( 内存、存储等 ) ,是一种完全无共享 (Share Nothing) 结构,因而扩展能力最好,理论上其扩展无限制,目前的技术可实现 512 个节点互联,数千个 CPU 。在 MPP 系统中,每个 SMP 节点也可以运行自己的操作系统、数据库等。但和 NUMA 不同的是,它不存在异地内存访问的问题。换言之,每个节点内的 CPU 不能访问另一个节点的内存。节点之间的信息交互是通过节点互联网络实现的,这个过程一般称为数据重分配 (Data Redistribution) 。但是 MPP 服务器需要一种复杂的机制来调度和平衡各个节点的负载和并行处理过程。

以上信息摘抄于[1],更多信息大家可以移步那里,里面的配图非常直观。

2. Linux和SMP

这里我们重点关注SMP,着眼其在Linux Kernel中是如何被支持,且给开发者提供了哪些数据结构和接口。关于Linux SMP,可以看看这篇短文[2],可以看其英文版[3]。关于Linux是如何支持SMP的,参考[4]。这个ppt是HUST的一位前辈做的,从Linux启动,进程调度,中断三个方面来说明Linux对SMP的支持,简单明了!

好,从数据结构和接口来看,即是per_cpu变量了,头文件包含在2.6的内核中是<linux/percpu.h>,在目前最新的是<asm-generic/percpu.h>,还有<linux/percpu-defs.h>。

先看看SMP的相关宏定义,

#ifndef PER_CPU_BASE_SECTION
#ifdef CONFIG_SMP
#define PER_CPU_BASE_SECTION ".data..percpu"
#else
#define PER_CPU_BASE_SECTION ".data"
#endif
#endif
 

可以看到如果有SMP的支持,则相应的section是.data..percpu。

编译期间分配:

DEFINE_PER_CPU(type, name);
避免进程在访问一个per-CPU变量时被切换到另外一个处理器上运行或被其它进程抢占:
get_cpu_var(变量)++;
put_cpu_var(变量);
访问其他处理器的变量副本用这个宏:
per_cpu(变量,int cpu_id);

动态分配与释放:

动态分配per-CPU变量:
void * alloc_percpu(type);
void * __alloc_percpu(size_t size, size_t align); //可以做特定的内存对齐
释放动态分配的per-CPU变量:
free_percpu();
访问动态分配的per-CPU变量的访问通过per_cpu_ptr完成:
per_cpu_ptr(void * per_cpu_var, int cpu_id);
 要想阻塞抢占,使用get_cpu()与put_cpu()即可:
int cpu = get_cpu();
ptr = per_cpu_ptr(per_cpu_var, cpu);
put_cpu();

导出per_cpu变量给模块:

EXPORT_PER_CPU_SYMBOL(per_cpu_var);
EXPORT_PER_CPU_SYMBOL_GPL(per_cpu_var);
要在模块中访问这样一个变量,应该这样做声明:
DECLARE_PER_CPU(type, name);

参考了[5],关于per_cpu变量是如何实现的,可以直接看Kernel的source code。

3. gtp_rb.c的分析

KGTP自己实现的Ring Buffer,让我们看一看具体的代码实现。

gtp_rb_s是一个结构体,作为指向一个Ring Buffer的指针,

struct gtp_rb_s {
     spinlock_t  lock;

     /* Pointer to the prev frame entry head.
        */
     void        *prev_frame;

     /* When write, this is the next address to be write.
        When read, this is the end of read.  */
     void        *w;

     /* When alloc memory from rb, record prev value W to PREV_W.
        When this memory doesn't need, set W back to PREV_W to release
        this memroy.  */
     void        *prev_w;

     /* Point to the begin of ring buffer.  Read will begin from R.  */
     void        *r;

     /* Point to the trace frame entry head of current read.  */
     void        *rp;

     /* This the id of rp point to.
        0 means rp doesn't point to a trace frame entry.
        So it need call gtp_rb_walk first.  */
     u64     rp_id;

     /* The cpu id.  */
     int     cpu;
 };

可以看到,用到了自旋锁,一个读指针,一个写指针,同时写还保存了上一次的写指针,可以方便释放内存。

再看相关变量,

static struct gtp_rb_s __percpu *gtp_rb;
 #if defined(CONFIG_ARM) && (LINUX_VERSION_CODE < KERNEL_VERSION(2,6,34))
 static atomic_t             gtp_rb_count;
 #else
 static atomic64_t           gtp_rb_count;
 #endif
 static unsigned int     gtp_rb_page_count;
 static atomic_t         gtp_rb_discard_page_number;

看到了吧,其中__percpu是一个宏定义,

# define __percpu       __attribute__((noderef, address_space(3)))

另外定义了一些全局的计数器。

下面分析核心的函数实现。

(1)初始化Ring Buffer

主要分配各自CPU的Ring Buffer的gtp_rb指针,不实际分配Ring Buffer的内存。

static int
 gtp_rb_init(void)
 {
     int cpu;

     gtp_rb = alloc_percpu(struct gtp_rb_s);
     if (!gtp_rb)
         return -ENOMEM;

     for_each_possible_cpu(cpu) {
         struct gtp_rb_s *rb
             = (struct gtp_rb_s *)per_cpu_ptr(gtp_rb, cpu);
         memset(rb, 0, sizeof(struct gtp_rb_s));
         rb->lock = __SPIN_LOCK_UNLOCKED(rb->lock);
         rb->cpu = cpu;
     }
     gtp_rb_page_count = 0;
     atomic_set(&p_rb_discard_page_number, 0);

     return 0;
 }

这里重点是:

<linux/percpu.h>中的宏定义以及<mm/percpu.c>的实现,

#define alloc_percpu(type)      \
         (typeof(type) __percpu *)__alloc_percpu(sizeof(type), __alignof__(type))
/**
836  * __alloc_percpu - allocate dynamic percpu area
837  * @size: size of area to allocate in bytes
838  * @align: alignment of area (max PAGE_SIZE)
839  *
840  * Allocate zero-filled percpu area of @size bytes aligned at @align.
841  * Might sleep.  Might trigger writeouts.
842  *
843  * CONTEXT:
844  * Does GFP_KERNEL allocation.
845  *
846  * RETURNS:
847  * Percpu pointer to the allocated area on success, NULL on failure.
848  */
849 void __percpu *__alloc_percpu(size_t size, size_t align)
850 {
851         return pcpu_alloc(size, align, false);
852 }
853 EXPORT_SYMBOL_GPL(__alloc_percpu);

<linux/cpumask.h>中的宏定义,
#define for_each_possible_cpu(cpu) for_each_cpu((cpu), cpu_possible_mask)
#define for_each_online_cpu(cpu)   for_each_cpu((cpu), cpu_online_mask)
#define for_each_present_cpu(cpu)  for_each_cpu((cpu), cpu_present_mask)

也可以关注下,spinlock是如何定义以及atomic_set。

(2)释放Ring Buffer

实际上是释放per_cpu变量gtp_rb,即每个CPU都有gtp_rb的一个副本。

 static void
 gtp_rb_release(void)
 {
     if (gtp_rb) {
         free_percpu(gtp_rb);
         gtp_rb = NULL;
     }
 }

(3)重置Ring Buffer的指针

初始化gtp_rb的读写指针,以及重置各个计数器。

static void
 gtp_rb_reset(void)
 {
     int cpu;

     for_each_possible_cpu(cpu) {
         struct gtp_rb_s *rb
             = (struct gtp_rb_s *)per_cpu_ptr(gtp_rb, cpu);
         rb->w = GTP_RB_DATA(rb->w);
         rb->r = rb->w;
         rb->rp = NULL;
         rb->rp_id = 0;
         rb->prev_frame = NULL;
     }

 #if defined(CONFIG_ARM) && (LINUX_VERSION_CODE < KERNEL_VERSION(2,6,34))
     atomic_set(&p_rb_count, 0);
 #else
     atomic64_set(&p_rb_count, 0);
 #endif
     atomic_set(&p_rb_discard_page_number, 0);
 }

这里我们可以看到per_cpu_ptr获取各个CPU变量副本的指针。

(4)计数器原子增

 static inline u64
 gtp_rb_clock(void)
 {
     u64 ret;

 re_inc:
 #if defined(CONFIG_ARM) && (LINUX_VERSION_CODE < KERNEL_VERSION(2,6,34))
     ret = (u64)atomic_inc_return(>p_rb_count);
 #else
     ret = atomic64_inc_return(>p_rb_count);
 #endif
     if (ret == 0)
         goto re_inc;

     return ret;
 }

有个goto在这里,直到成功,函数再退出。

有个相关判断Ring Buffer是否为空的宏,

 #define GTP_RB_PAGE_IS_EMPTY    (gtp_rb_page_count == 0)

(5)分配内存页给Ring Buffer

核心部分了,分配页内存。

static int
 gtp_rb_page_alloc(int size)
 {
     int cpu;

     for_each_possible_cpu(cpu) {
         struct gtp_rb_s *rb
             = (struct gtp_rb_s *)per_cpu_ptr(gtp_rb, cpu);
         void        *last = NULL, *next = NULL;
         struct page *page;
         int     current_size;

         gtp_rb_page_count = 0;
         current_size = size;

         while (1) {
             if (current_size > 0)
                 current_size -= PAGE_SIZE;
             else
                 break;

             page = alloc_pages_node(cpu_to_node(cpu),
                         GFP_KERNEL, 0);
             if (!page)
                 return -1;
             gtp_rb_page_count++;
             rb->w = GTP_RB_DATA(page_address(page));
             GTP_RB_NEXT(rb->w) = next;
             if (next)
                 GTP_RB_PREV(next) = rb->w;
             next = rb->w;
             if (!last)
                 last = rb->w;
         }

         GTP_RB_NEXT(last) = next;
         GTP_RB_PREV(next) = last;
         rb->r = rb->w;

         GTP_RB_NEXT(last) = next;
         GTP_RB_PREV(next) = last;
         rb->r = rb->w;

         if (gtp_rb_page_count < 3)
             return -1;
     }

     return 0;
 }

传入的参数size为可分配空间的大小,单位是byte。首先使用for_each_possible_cpu遍历CPU,然后通过per_cpu_ptr得到每个gtp_rb指针,之后使用alloc_pages_node分配PAGE,最后会得到一个双链表Ring Buffer。

让我们重点看alloc_pages_node,

static inline struct page *alloc_pages_node(int nid, gfp_t gfp_mask,
313                                                 unsigned int order)
314 {
315         /* Unknown node is current node */
316         if (nid < 0)
317                 nid = numa_node_id();
318 
319         return __alloc_pages(gfp_mask, order, node_zonelist(nid, gfp_mask));
320 }

再看__alloc_pages,

static inline struct page *
306 __alloc_pages(gfp_t gfp_mask, unsigned int order,
307                 struct zonelist *zonelist)
308 {
309         return __alloc_pages_nodemask(gfp_mask, order, zonelist, NULL);
310 }

继续追踪,__alloc_pages_nodemask,

/*
2722  * This is the 'heart' of the zoned buddy allocator.
2723  */
2724 struct page *
2725 __alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order,
2726                         struct zonelist *zonelist, nodemask_t *nodemask)
2727 {
2728         enum zone_type high_zoneidx = gfp_zone(gfp_mask);
2729         struct zone *preferred_zone;
2730         struct zoneref *preferred_zoneref;
2731         struct page *page = NULL;
2732         int migratetype = allocflags_to_migratetype(gfp_mask);
2733         unsigned int cpuset_mems_cookie;
2734         int alloc_flags = ALLOC_WMARK_LOW|ALLOC_CPUSET|ALLOC_FAIR;
2735         int classzone_idx;
2736 
2737         gfp_mask &= gfp_allowed_mask;
2738 
2739         lockdep_trace_alloc(gfp_mask);
2740 
2741         might_sleep_if(gfp_mask & __GFP_WAIT);
2742 
2743         if (should_fail_alloc_page(gfp_mask, order))
2744                 return NULL;
2745 
2746         /*
2747          * Check the zones suitable for the gfp_mask contain at least one
2748          * valid zone. It's possible to have an empty zonelist as a result
2749          * of GFP_THISNODE and a memoryless node
2750          */
2751         if (unlikely(!zonelist->_zonerefs->zone))
2752                 return NULL;
2753 
2754 retry_cpuset:
2755         cpuset_mems_cookie = read_mems_allowed_begin();
2756 
2757         /* The preferred zone is used for statistics later */
2758         preferred_zoneref = first_zones_zonelist(zonelist, high_zoneidx,
2759                                 nodemask ? : &cpuset_current_mems_allowed,
2760                                 &preferred_zone);
2761         if (!preferred_zone)
2762                 goto out;
2763         classzone_idx = zonelist_zone_idx(preferred_zoneref);
2764 
2765 #ifdef CONFIG_CMA
2766         if (allocflags_to_migratetype(gfp_mask) == MIGRATE_MOVABLE)
2767                 alloc_flags |= ALLOC_CMA;
2768 #endif
2769 retry:
2770         /* First allocation attempt */
2771         page = get_page_from_freelist(gfp_mask|__GFP_HARDWALL, nodemask, order,
2772                         zonelist, high_zoneidx, alloc_flags,
2773                         preferred_zone, classzone_idx, migratetype);
2774         if (unlikely(!page)) {
2775                 /*
2776                  * The first pass makes sure allocations are spread
2777                  * fairly within the local node.  However, the local
2778                  * node might have free pages left after the fairness
2779                  * batches are exhausted, and remote zones haven't
2780                  * even been considered yet.  Try once more without
2781                  * fairness, and include remote zones now, before
2782                  * entering the slowpath and waking kswapd: prefer
2783                  * spilling to a remote zone over swapping locally.
2784                  */
2785                 if (alloc_flags & ALLOC_FAIR) {
2786                         reset_alloc_batches(zonelist, high_zoneidx,
2787                                             preferred_zone);
2788                         alloc_flags &= ~ALLOC_FAIR;
2789                         goto retry;
2790                 }
2791                 /*
2792                  * Runtime PM, block IO and its error handling path
2793                  * can deadlock because I/O on the device might not
2794                  * complete.
2795                  */
2796                 gfp_mask = memalloc_noio_flags(gfp_mask);
2797                 page = __alloc_pages_slowpath(gfp_mask, order,
2798                                 zonelist, high_zoneidx, nodemask,
2799                                 preferred_zone, classzone_idx, migratetype);
2800         }
2801 
2802         trace_mm_page_alloc(page, order, gfp_mask, migratetype);
2803 
2804 out:
2805         /*
2806          * When updating a task's mems_allowed, it is possible to race with
2807          * parallel threads in such a way that an allocation can fail while
2808          * the mask is being updated. If a page allocation is about to fail,
2809          * check if the cpuset changed during allocation and if so, retry.
2810          */
2811         if (unlikely(!page && read_mems_allowed_retry(cpuset_mems_cookie)))
2812                 goto retry_cpuset;
2813 
2814         return page;
2815 }
2816 EXPORT_SYMBOL(__alloc_pages_nodemask);

唔,需要再深入了解一下buddy system了,关于以上函数,可以参考[6]。

(6)页内存的释放

static void
 gtp_rb_page_free(void)
 {
     int cpu;

     for_each_possible_cpu(cpu) {
         struct gtp_rb_s *rb
             = (struct gtp_rb_s *)per_cpu_ptr(gtp_rb, cpu);
         void        *need_free = NULL;
         int     is_first = 1;

         for (rb->r = rb->w = GTP_RB_DATA(rb->w);
              is_first || rb->w != rb->r;
              rb->w = GTP_RB_NEXT(rb->w)) {
             if (need_free)
                 free_page((unsigned long)need_free);
             need_free = GTP_RB_HEAD(rb->w);
             is_first = 0;
         }
         if (need_free)
             free_page((unsigned long)need_free);
     }

     gtp_rb_page_count = 0;
 }

嗯,跟上面的分配对着看吧。

(7)一些宏定义

/* Following macros is for page of ring buffer.  */
 #define ADDR_SIZE       sizeof(size_t)
 #define GTP_RB_HEAD(addr)   ((void *)((size_t)(addr) & PAGE_MASK))
 #define GTP_RB_DATA(addr)   (GTP_RB_HEAD(addr) + ADDR_SIZE)
 #define GTP_RB_END(addr)    (GTP_RB_HEAD(addr) + PAGE_SIZE - ADDR_SIZE)
 #define GTP_RB_PREV(addr)   (*(void **)GTP_RB_HEAD(addr))
 #define GTP_RB_NEXT(addr)   (*(void **)GTP_RB_END(addr))
 #define GTP_RB_DATA_MAX     (PAGE_SIZE - ADDR_SIZE - ADDR_SIZE - FID_SIZE \
                  - sizeof(u64))

 #define GTP_RB_LOCK(r)          spin_lock(&r->lock)
 #define GTP_RB_UNLOCK(r)        spin_unlock(&r->lock)
 #define GTP_RB_LOCK_IRQ(r, flags)   spin_lock_irqsave(&r->lock, flags)
 #define GTP_RB_UNLOCK_IRQ(r, flags) spin_unlock_irqrestore(&r->lock, flags)
 #define GTP_RB_RELEASE(r)       (r->w = r->prev_w)

(8)一些utilites

static inline void *
 gtp_rb_prev_frame_get(struct gtp_rb_s *rb)
 {
     return rb->prev_frame;
 }

 static inline void
 gtp_rb_prev_frame_set(struct gtp_rb_s *rb, void *prev_frame)
 {
     rb->prev_frame = prev_frame;
 }

 /* Return the max size for next gtp_rb_alloc.  */
 static inline size_t
 gtp_rb_alloc_max(struct gtp_rb_s *rb)
 {
         return GTP_RB_END(rb->w) - rb->w;
 }

(9)页内分配内存

static void *
 gtp_rb_alloc(struct gtp_rb_s *rb, size_t size, u64 id)
 {
     void        *ret;

     size = FRAME_ALIGN(size);

     if (size > GTP_RB_DATA_MAX) {
         printk(KERN_WARNING "gtp_rb_alloc: The size %zu is too big"
                     "for the KGTP ring buffer.  "
                     "The max size that KGTP ring buffer "
                     "support is %lu (Need sub some size for "
                     "inside structure).\n", size, GTP_RB_DATA_MAX);
         return NULL;
     }

     rb->prev_w = rb->w;

     if (rb->w + size > GTP_RB_END(rb->w)) {
         /* Don't have enough size in current page, insert a
            FID_PAGE_END and try to get next page.  */
         if (GTP_RB_END(rb->w) - rb->w >= FID_SIZE)
             FID(rb->w) = FID_PAGE_END;

         if (GTP_RB_HEAD(GTP_RB_NEXT(rb->w)) == GTP_RB_HEAD(rb->r)) {
             if (gtp_circular) {
                 rb->r = GTP_RB_NEXT(rb->r);
                 atomic_inc(>p_rb_discard_page_number);
             } else
                 return NULL;
         }
         rb->w = GTP_RB_NEXT(rb->w);

         if (id) {
             /* Need insert a FID_PAGE_BEGIN.  */
             FID(rb->w) = FID_PAGE_BEGIN;
             *((u64 *)(rb->w + FID_SIZE)) = id;
             rb->w += FRAME_ALIGN(GTP_FRAME_PAGE_BEGIN_SIZE);
         }
     }

     ret = rb->w;
     rb->w += size;

     return ret;
 }

往PAGE中存放trace frame信息,需要调用这个函数,这里需要注意到内存对齐,

/* GTP_FRAME_SIZE must align with FRAME_ALIGN_SIZE if use GTP_FRAME_SIMPLE.  */
 #define GTP_FRAME_SIZE          5242880 // 5*1024*1024 = 5MB
 #define GTP_FRAME_SIZE_MIN      12288   // 12*1024 = 12KB = 3 PAGES
 #if defined(GTP_FRAME_SIMPLE) || defined(GTP_RB)
 #define FRAME_ALIGN_SIZE    sizeof(unsigned int)
 #define FRAME_ALIGN(x)      ((x + FRAME_ALIGN_SIZE - 1) \
                  & (~(FRAME_ALIGN_SIZE - 1)))
 #endif

接下来还有几个函数,待之后再分析。

4. 需要理解的几个问题

通过对以上代码的分析,需要理解以下几个问题:
(1)内存对齐
(2)Linux内存管理,页分配机制
(3)Linux SMP的支持,per_cpu的使用
(4)双链表Ring Buffer的实现
(5)原子操作

5. 参考链接

[1] http://www.cnblogs.com/yubo/archive/2010/04/23/1718810.html
[2] http://www.ibm.com/developerworks/cn/linux/l-linux-smp/
[3] http://www.ibm.com/developerworks/library/l-linux-smp/
[4] grid.hust.edu.cn/chc/download/Linux_smp.ppt
[5] http://blog.csdn.net/nancygreen/article/details/8788394
[6] http://www.makelinux.net/ldd3/chp-8-sect-3
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值