Linux内存烧坏现象,Linux-3.14.12内存管理笔记【内存破坏检测kmemcheck分析】

kmemcheck和kmemleak是linux在2.6.31版本开始对外提供的内核内存管理方面的两个检测工具,最初仅支持x86环境,时至今日已经能够支持arm环境了。其中kmemcheck主要是用于内核内存破坏检测,而kmemleak则是用于内核内存泄露检测。本文主要分析kmemcheck的实现,至于kmemleak下一篇文章再详细介绍。

为什么要有kmemcheck?古人云:“人非圣贤孰能无过”,程序员也会犯错,但是这不能作为犯错的辩解。也是由于C语言的强大,几乎可以访问所有内存,加上程序开发时的考虑不周或者疏忽等情况,极有可能操作越界或者访问了未初始化的内存空间等,导致了内存破坏或者改写,这时则需要一个能够记录导致错误的日志信息。对此,kmemcheck应运而生。

kmemcheck的设计思路是分配内存页面的同时分配等量的影子内存,所有对分配出来的内存的操作,都将被影子内存所“替代”,也就是该操作都会先通过影子内存,经检测内存操作的“合法性”后,最终才会落入到实际的内存页面中,对于所有检测出来的“非法”操作,都将会被记录下来。

其具体工作原理可以通过分配内存、访问内存、释放内存以及错误处理四个方面进行了解:(注:该描述来自于IBM工程师的一篇文章:

1、分配内存

对分配到的内存数据页面(分配标志中不包含__GFP_NOTRACK,__GFP_HIGHMEM,对于slab cache的内存,cache创建时标志中不包含SLAB_NOTRACK),kmemcheck会为其分配相同数量的影子页面(在分配影子页面时,置位了__GFP_NOTRACK标志位,所以它自己不会被kmemcheck跟踪),数据页面通过其page结构体中的shadow指针和影子页面联系起来。然后影子页面中的每个字节会标志为未初始化状态,同时将数据页面对应的页表项中_PAGE_PRESENT标志位清零(这样访问该数据页面时会引发页面异常),并置位_PAGE_HIDDEN标志位来表明该页面是被kmemcheck跟踪的。

2、访问内存

由于在分配过程中将数据页面对应的页表项中的_PAGE_PRESENT清零了,因此对该数据页面的访问会引发一次页面异常,在do_page_fault函数处理过程中,如果它发现页表项属性中的_PAGE_HIDDEN置位了,那么说明该页面是被Kmemcheck跟踪的,接下来就会进入kmemcheck的处理流程,其中会根据该次内存访问地址所对应的影子页面中的内容来检查这次访问是否是合法的,如果是非法的那么它就会将预先设置好的一个tasklet(该tasklet负责错误处理)插入到当前CPU的tasklet队列中,然后去触发一个软中断,这样在中断的下半部分就会执行这个tasklet。接下来kmemcheck会将影子页面中对应本次内存访问地址的内存区域标识为初始化状态(防止同一个地址警告两次),同时将数据页面页表项中的_PAGE_PRESENT置位,并将CPU标志寄存器TF置位开启单步调试功能,这样当页面异常处理返回后,CPU会重新执行触发异常的指令,而这次是可以正确执行的。但是执行该指令完毕后,由于TF标志位置位了,所以在执行下一条指令之前,系统会进入调试陷阱(debug trap),在其处理函数do_trap中,kmemcheck又会清零该数据页面页表项中的_PAGE_PRESENT属性标志位(并且清零标志寄存器中的TF位),从而当下次再访问到这个页面时,又会引发一次页面异常。

3、释放内存

影子页面会随着数据页面的释放而被释放,因此当数据页面被释放之后,如果再去访问该页面,不会出现kmemcheck报警。

4、错误处理

kmemcheck 用了一个循环缓冲区(包含了 CONFIG_KMEMCHECK_QUEUE_SIZE个元素)来记录每次的警告信息,包括警告类型,引发警告的内存地址及其访问长度,各寄存器的值和stack trace,同时还将访问地址附近(起始地址:以2的CONFIG_KMEMCHECK_SHADOW_COPY_SHIFT次幂大小对该地址进行圆整后的值;大小:2的CONFIG_KMEMCHECK_SHADOW_COPY_SHIFT次幂)的数据页面和其对应影子页面中的内容保存在记录中(由同一指令地址引发的相邻的两次警告不会被重复记录)。当前文中注册的tasklet被调度执行时,会将循环缓冲区中所有的记录都打印出来。

了解过kmemcheck实现原理后,下面分析一下其代码显示,其中kmemcheck模块的函数入口为kmemcheck_init():

【file:/arch/x86/mm/kmemcheck/kmemcheck.c】

int __init kmemcheck_init(void)

{

#ifdef CONFIG_SMP

/*

* Limit SMP to use a single CPU. We rely on the fact that this code

* runs before SMP is set up.

*/

if (setup_max_cpus > 1) {

printk(KERN_INFO

"kmemcheck: Limiting number of CPUs to 1.\n");

setup_max_cpus = 1;

}

#endif

if (!kmemcheck_selftest()) {

printk(KERN_INFO "kmemcheck: self-tests failed; disabling\n");

kmemcheck_enabled = 0;

return -EINVAL;

}

printk(KERN_INFO "kmemcheck: Initialized\n");

return 0;

}

early_initcall(kmemcheck_init)

该函数通过early_initcall()的方式注册到linux内核初始化中,其将在kernel_init()内核线程中被调用初始化,调用路径kernel_init()->kernel_init_freeable()->do_pre_smp_initcalls()->do_one_initcall()->kmemcheck_init(),至于其注册和被调用的方式实现将在后面的kswapd内核守护线程中进行分析,这里就不赘述了。

进一步分析kmemcheck_selftest()函数实现。

【file:/arch/x86/mm/kmemcheck/selftest.c】

bool kmemcheck_selftest(void)

{

bool pass = true;

pass = pass && selftest_opcodes_all();

return pass;

}

该函数主要封装selftest_opcodes_all()函数;

【file:/arch/x86/mm/kmemcheck/selftest.c】

static bool selftest_opcodes_all(void)

{

bool pass = true;

unsigned int i;

for (i = 0; i < ARRAY_SIZE(selftest_opcodes); ++i)

pass = pass && selftest_opcode_one(&selftest_opcodes[i]);

return pass;

}

至于selftest_opcodes_all()则主要是通过遍历selftest_opcodes[]数组列表,对CPU的操作码进行解码操作。具体的selftest_opcodes定义如下。

【file:/arch/x86/mm/kmemcheck/selftest.c】

static const struct selftest_opcode selftest_opcodes[] = {

/* REP MOVS */

{1, "\xf3\xa4", "rep movsb , "},

{4, "\xf3\xa5", "rep movsl , "},

/* MOVZX / MOVZXD */

{1, "\x66\x0f\xb6\x51\xf8", "movzwq , "},

{1, "\x0f\xb6\x51\xf8", "movzwq , "},

/* MOVSX / MOVSXD */

{1, "\x66\x0f\xbe\x51\xf8", "movswq , "},

{1, "\x0f\xbe\x51\xf8", "movswq , "},

#ifdef CONFIG_X86_64

/* MOVZX / MOVZXD */

{1, "\x49\x0f\xb6\x51\xf8", "movzbq , "},

{2, "\x49\x0f\xb7\x51\xf8", "movzbq , "},

/* MOVSX / MOVSXD */

{1, "\x49\x0f\xbe\x51\xf8", "movsbq , "},

{2, "\x49\x0f\xbf\x51\xf8", "movsbq , "},

{4, "\x49\x63\x51\xf8", "movslq , "},

#endif

};

selftest_opcode_one()将会对selftest_opcodes[]的每项操作码信息进行解析。

【file:/arch/x86/mm/kmemcheck/selftest.c】

static bool selftest_opcode_one(const struct selftest_opcode *op)

{

unsigned size;

kmemcheck_opcode_decode(op->insn, &size);

if (size == op->expected_size)

return true;

printk(KERN_WARNING "kmemcheck: opcode %s: expected size %d, got %d\n",

op->desc, op->expected_size, size);

return false;

}

该函数会调用kmemcheck_opcode_decode()对操作码解码操作,这是一个非常粗暴的解码函数实现,主要是从其他指令中区别出读写指令及其大小,返回实际指令长度以及前缀操作码。最终实际上是对读写指令做操作码检验,至于为什么在初始化函数中调用,只是做一下检验?有待考究。

【file:/arch/x86/mm/kmemcheck/opcode.c】

/*

* This is a VERY crude opcode decoder. We only need to find the size of the

* load/store that caused our #PF and this should work for all the opcodes

* that we care about. Moreover, the ones who invented this instruction set

* should be shot.

*/

void kmemcheck_opcode_decode(const uint8_t *op, unsigned int *size)

{

/* Default operand size */

int operand_size_override = 4;

/* prefixes */

for (; opcode_is_prefix(*op); ++op) {

if (*op == 0x66)

operand_size_override = 2;

}

/* REX prefix */

if (opcode_is_rex_prefix(*op)) {

uint8_t rex = *op;

++op;

if (rex & REX_W) {

switch (*op) {

case 0x63:

*size = 4;

return;

case 0x0f:

++op;

switch (*op) {

case 0xb6:

case 0xbe:

*size = 1;

return;

case 0xb7:

case 0xbf:

*size = 2;

return;

}

break;

}

*size = 8;

return;

}

}

/* escape opcode */

if (*op == 0x0f) {

++op;

/*

* This is move with zero-extend and sign-extend, respectively;

* we don't have to think about 0xb6/0xbe, because this is

* already handled in the conditional below.

*/

if (*op == 0xb7 || *op == 0xbf)

operand_size_override = 2;

}

*size = (*op & 1) ? operand_size_override : 1;

}

kmemcheck模块初始化,实际上是没做什么事情的,只是检验一下处理器的操作码,至少从代码实现中是如此的。个人感觉是为了检验代码的有效性,直接写在初始化中,以便系统启动后就可以观察到该解析函数是否有效(以上为个人愚见)。

而实际上的kmemcheck如何实现内存检查的,接下来进行分析一下。首先是分配影子内存,该函数入口为kmemcheck_alloc_shadow(),具体实现:

【file:/mm/kmemcheck.c】

void kmemcheck_alloc_shadow(struct page *page, int order, gfp_t flags, int node)

{

struct page *shadow;

int pages;

int i;

pages = 1 << order;

/*

* With kmemcheck enabled, we need to allocate a memory area for the

* shadow bits as well.

*/

shadow = alloc_pages_node(node, flags | __GFP_NOTRACK, order);

if (!shadow) {

if (printk_ratelimit())

printk(KERN_ERR "kmemcheck: failed to allocate "

"shadow bitmap\n");

return;

}

for(i = 0; i < pages; ++i)

page[i].shadow = page_address(&shadow[i]);

/*

* Mark it as non-present for the MMU so that our accesses to

* this memory will trigger a page fault and let us analyze

* the memory accesses.

*/

kmemcheck_hide_pages(page, pages);

}

该函数主要在__alloc_pages_slowpath()、kmem_getpages()及allocate_slab()中被直接或者间接被调用,其针对的是内存页面的分配使用影子内存。其实现主要是通过alloc_pages_node()分配等大小的内存页面空间,然后将入参传入分配出去的page页面进行影子内存关联,最后通过kmemcheck_hide_pages()来对分配出去的页面进行隐藏。

具体的页面隐藏实现:

【file:/arch/x86/mm/kmemcheck/kmemcheck.c】

void kmemcheck_hide_pages(struct page *p, unsigned int n)

{

unsigned int i;

for (i = 0; i < n; ++i) {

unsigned long address;

pte_t *pte;

unsigned int level;

address = (unsigned long) page_address(&p[i]);

pte = lookup_address(address, &level);

BUG_ON(!pte);

BUG_ON(level != PG_LEVEL_4K);

set_pte(pte, __pte(pte_val(*pte) & ~_PAGE_PRESENT));

set_pte(pte, __pte(pte_val(*pte) | _PAGE_HIDDEN));

__flush_tlb_one(address);

}

}

该函数主要是通过查找页面对应的PTE页表项,通过设置页表项的PRESENT标志位对页面进行隐藏。至此,分配影子页面的动作已经完成,但是实际上还有一个动作,在__alloc_pages_slowpath()、kmem_getpages()及allocate_slab()等函数调用之后,会根据是否存在内存页面的构造函数而分别调用kmemcheck_mark_uninitialized_pages()、kmemcheck_mark_unallocated_pages ()或kmemcheck_mark_initialized_pages()函数。

这三个函数都主要是通过对kmemcheck_mark_uninitialized()、kmemcheck_mark_unallocated()及kmemcheck_mark_initialized()函数的封装,最后调用了mark_shadow()。以kmemcheck_mark_uninitialized_pages()函数实现为例:

【file:/arch/x86/mm/kmemcheck/shadow.c】

void kmemcheck_mark_uninitialized_pages(struct page *p, unsigned int n)

{

unsigned int i;

for (i = 0; i < n; ++i)

kmemcheck_mark_uninitialized(page_address(&p[i]), PAGE_SIZE);

}

其通过循环调用kmemcheck_mark_uninitialized()函数,以实现对影子内存的标识,而kmemcheck_mark_uninitialized()的实现则是:

【file:/arch/x86/mm/kmemcheck/shadow.c】

void kmemcheck_mark_uninitialized(void *address, unsigned int n)

{

mark_shadow(address, n, KMEMCHECK_SHADOW_UNINITIALIZED);

}

最终调用到了mark_shadow()函数来对影子内存进行标记,其中对于未初始化的内存使用的标识为KMEMCHECK_SHADOW_UNINITIALIZED。

而kmemcheck检测内存就是通过这种对影子内存的标识来识别程序对内存访问的问题,标识类型共有四种,分别是未分配、未初始化、已初始化以及已释放。类型的具体定义为:

【file:/arch/x86/mm/kmemcheck/shadow.c】

enum kmemcheck_shadow {

KMEMCHECK_SHADOW_UNALLOCATED,

KMEMCHECK_SHADOW_UNINITIALIZED,

KMEMCHECK_SHADOW_INITIALIZED,

KMEMCHECK_SHADOW_FREED,

};

常规的情况下,如果内存存在构造函数的情况,影子内存将被设置为未初始状态,主要是由于构造函数会对内存进行设置但未是全面性的,所以需要设置为未初始化;而对于内存不存在构造函数的情况,将会被设置为未分配;至于标识为初始化的的情况则是基于内存请求时携带的标志位,如果标志位指明内存需要置0的情况,则会设置为初始化;最后的内存为已释放状态,则是在内存释放的时候会被设置上去的。

回到正文,接下来分析mark_shadow()的具体实现:

【file:/arch/x86/mm/kmemcheck/shadow.c】

static void mark_shadow(void *address, unsigned int n,

enum kmemcheck_shadow status)

{

unsigned long addr = (unsigned long) address;

unsigned long last_addr = addr + n - 1;

unsigned long page = addr & PAGE_MASK;

unsigned long last_page = last_addr & PAGE_MASK;

unsigned int first_n;

void *shadow;

/* If the memory range crosses a page boundary, stop there. */

if (page == last_page)

first_n = n;

else

first_n = page + PAGE_SIZE - addr;

shadow = kmemcheck_shadow_lookup(addr);

if (shadow)

memset(shadow, status, first_n);

addr += first_n;

n -= first_n;

/* Do full-page memset()s. */

while (n >= PAGE_SIZE) {

shadow = kmemcheck_shadow_lookup(addr);

if (shadow)

memset(shadow, status, PAGE_SIZE);

addr += PAGE_SIZE;

n -= PAGE_SIZE;

}

/* Do the remaining page, if any. */

if (n > 0) {

shadow = kmemcheck_shadow_lookup(addr);

if (shadow)

memset(shadow, status, n);

}

}

该函数主要是通过kmemcheck_shadow_lookup()查找到分配的内存空间对应的影子内存,而后将影子内存相应的空间大小根据内存的目标状态进行memset标记。至此,对分配的内存页面的kmemcheck准备已经完成。

前面分析的是内存页面的kmemcheck设置,而对于小块的slab内存设置的实现则在kmemcheck_slab_alloc()中,该函数主要是在slab内存分配的时候,slab_alloc_node()、slab_alloc()或slab_post_alloc_hook()函数内被调用。具体实现如下:

【file:/mm/kmemcheck.c】

void kmemcheck_slab_alloc(struct kmem_cache *s, gfp_t gfpflags, void *object,

size_t size)

{

/*

* Has already been memset(), which initializes the shadow for us

* as well.

*/

if (gfpflags & __GFP_ZERO)

return;

/* No need to initialize the shadow of a non-tracked slab. */

if (s->flags & SLAB_NOTRACK)

return;

if (!kmemcheck_enabled || gfpflags & __GFP_NOTRACK) {

/*

* Allow notracked objects to be allocated from

* tracked caches. Note however that these objects

* will still get page faults on access, they just

* won't ever be flagged as uninitialized. If page

* faults are not acceptable, the slab cache itself

* should be marked NOTRACK.

*/

kmemcheck_mark_initialized(object, size);

} else if (!s->ctor) {

/*

* New objects should be marked uninitialized before

* they're returned to the called.

*/

kmemcheck_mark_uninitialized(object, size);

}

}

此函数类似于内存页面分配时的影子内存设置一样,先行经过标志判断后,继而根据内存的情况进行影子内存设置为已初始化或者未初始化的状态。。

最后,继而分析如何触发kmemcheck检测的。kmemcheck的检测主要是借助于缺页异常处理,由于内存页面设置为不在位,所以访问内存时必将导致缺页异常,继而在异常处理中对内存的访问操作进行解析,根据影子内存的标志记录信息。该解析函数为kmemcheck_fault(),具体实现:

【file:/arch/x86/mm/kmemcheck/kmemcheck.c】

bool kmemcheck_fault(struct pt_regs *regs, unsigned long address,

unsigned long error_code)

{

pte_t *pte;

/*

* XXX: Is it safe to assume that memory accesses from virtual 86

* mode or non-kernel code segments will _never_ access kernel

* memory (e.g. tracked pages)? For now, we need this to avoid

* invoking kmemcheck for PnP BIOS calls.

*/

if (regs->flags & X86_VM_MASK)

return false;

if (regs->cs != __KERNEL_CS)

return false;

pte = kmemcheck_pte_lookup(address);

if (!pte)

return false;

WARN_ON_ONCE(in_nmi());

if (error_code & 2)

kmemcheck_access(regs, address, KMEMCHECK_WRITE);

else

kmemcheck_access(regs, address, KMEMCHECK_READ);

kmemcheck_show(regs);

return true;

}

该函数在缺页处理函数中被调用do_page_fault()->__do_page_fault()->kmemcheck_fault()。其首先对flags寄存器及cs段寄存器进行检验,避免被PnP BIOS调用到kmemcheck的函数;继而通过kmemcheck_pte_lookup()查找具有隐藏属性的页表项,如果找不到页表项,表示这是正常的缺页异常;最后通过入参中的error_code进行判断当前引发缺页异常的是程序对内存进行何种操作,使用相应的传参调用kmemcheck_access();函数末了,将调用kmemcheck_show()进行日志记录。

具体分析一下kmemcheck_access()的实现:

【file:/arch/x86/mm/kmemcheck/kmemcheck.c】

static void kmemcheck_access(struct pt_regs *regs,

unsigned long fallback_address, enum kmemcheck_method fallback_method)

{

const uint8_t *insn;

const uint8_t *insn_primary;

unsigned int size;

struct kmemcheck_context *data = &__get_cpu_var(kmemcheck_context);

/* Recursive fault -- ouch. */

if (data->busy) {

kmemcheck_show_addr(fallback_address);

kmemcheck_error_save_bug(regs);

return;

}

data->busy = true;

insn = (const uint8_t *) regs->ip;

insn_primary = kmemcheck_opcode_get_primary(insn);

kmemcheck_opcode_decode(insn, &size);

switch (insn_primary[0]) {

#ifdef CONFIG_KMEMCHECK_BITOPS_OK

/* AND, OR, XOR */

/*

* Unfortunately, these instructions have to be excluded from

* our regular checking since they access only some (and not

* all) bits. This clears out "bogus" bitfield-access warnings.

*/

case 0x80:

case 0x81:

case 0x82:

case 0x83:

switch ((insn_primary[1] >> 3) & 7) {

/* OR */

case 1:

/* AND */

case 4:

/* XOR */

case 6:

kmemcheck_write(regs, fallback_address, size);

goto out;

/* ADD */

case 0:

/* ADC */

case 2:

/* SBB */

case 3:

/* SUB */

case 5:

/* CMP */

case 7:

break;

}

break;

#endif

/* MOVS, MOVSB, MOVSW, MOVSD */

case 0xa4:

case 0xa5:

/*

* These instructions are special because they take two

* addresses, but we only get one page fault.

*/

kmemcheck_copy(regs, regs->si, regs->di, size);

goto out;

/* CMPS, CMPSB, CMPSW, CMPSD */

case 0xa6:

case 0xa7:

kmemcheck_read(regs, regs->si, size);

kmemcheck_read(regs, regs->di, size);

goto out;

}

/*

* If the opcode isn't special in any way, we use the data from the

* page fault handler to determine the address and type of memory

* access.

*/

switch (fallback_method) {

case KMEMCHECK_READ:

kmemcheck_read(regs, fallback_address, size);

goto out;

case KMEMCHECK_WRITE:

kmemcheck_write(regs, fallback_address, size);

goto out;

}

out:

data->busy = false;

}

该函数通过data->busy的判断,确认是否发生了kmemcheck嵌套错误,如果是的话,kmemcheck_show_addr()先将已隐藏的内存页面设置为可见,然后通过kmemcheck_error_save_bug()记录日志信息;如果没有发生嵌套错误,接下来将设置data->busy防止嵌套,通过kmemcheck_opcode_get_primary()识别并跳过指令前缀,再是kmemcheck_opcode_decode()识别处理器的操作码;继而是两个switch-case,第一个是通过识别kmemcheck_opcode_get_primary()返回的指令操作码,进行相应的处理,而第二个则是鉴于指令无法正确识别,那么将通过使用缺页异常的数据去判断该地址及其的访问方式。最终调用kmemcheck_read()处理内存读操作、kmemcheck_write()处理内存写操作、kmemcheck_copy()则处理内存拷贝触发的kmemcheck。这三个处理函数的实现都是大同小异,仅kmemcheck_copy()处理稍微特殊,需要分别识别处理两个地址。

接下来以kmemcheck_read()为例进行分析:

【file:/arch/x86/mm/kmemcheck/kmemcheck.c】

/* Access may cross page boundary */

static void kmemcheck_read(struct pt_regs *regs,

unsigned long addr, unsigned int size)

{

unsigned long page = addr & PAGE_MASK;

unsigned long next_addr = addr + size - 1;

unsigned long next_page = next_addr & PAGE_MASK;

if (likely(page == next_page)) {

kmemcheck_read_strict(regs, addr, size);

return;

}

/*

* What we do is basically to split the access across the

* two pages and handle each part separately. Yes, this means

* that we may now see reads that are 3 + 5 bytes, for

* example (and if both are uninitialized, there will be two

* reports), but it makes the code a lot simpler.

*/

kmemcheck_read_strict(regs, addr, next_page - addr);

kmemcheck_read_strict(regs, next_page, next_addr - next_page);

}

该函数主要通过给定的addr以及size进行校验判断,识别当前要检测的内存空间是否存在跨内存页面的情况,如果存在,则将空间按照页面进行划分后再行通过kmemcheck_read_strict()进行检查。

至于kmemcheck_read_strict():

【file:/arch/x86/mm/kmemcheck/kmemcheck.c】

/* Access may NOT cross page boundary */

static void kmemcheck_read_strict(struct pt_regs *regs,

unsigned long addr, unsigned int size)

{

void *shadow;

enum kmemcheck_shadow status;

shadow = kmemcheck_shadow_lookup(addr);

if (!shadow)

return;

kmemcheck_save_addr(addr);

status = kmemcheck_shadow_test(shadow, size);

if (status == KMEMCHECK_SHADOW_INITIALIZED)

return;

if (kmemcheck_enabled)

kmemcheck_error_save(status, addr, size, regs);

if (kmemcheck_enabled == 2)

kmemcheck_enabled = 0;

/* Don't warn about it again. */

kmemcheck_shadow_set(shadow, size);

}

此函数先是kmemcheck_shadow_lookup()查找该地址的影子内存,继而如果确认是kmemcheck检查的内存,则会kmemcheck_save_addr()将地址信息保存到当前CPU的kmemcheck_context结构信息中;接着kmemcheck_shadow_test()检查不超过页边界的读操作,主要检查对应影子中记录的内存状态是否合法,如有错,则kmemcheck_error_save()记录错误信息、出错上下文等;最后kmemcheck_shadow_set()标记本次检查过的内存影子为“初始化”,避免二次报错。

至此,kmemcheck的内存检查代码实现分析完毕。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值