内核同步方法

本文转自

愷风(Wei)的专栏

lizhiyong1983的专栏



内核同步方法


一、原子变量


设置原子量的值

staticinline void atomic_set(atomic_t *v, int i)

{

unsignedlong tmp;


__asm____volatile__("@ atomic_set\n"

"1: ldrex %0, [%1]\n"

" strex %0, %2, [%1]\n"

" teq %0, #0\n"

" bne 1b"

: "=&r" (tmp)

:"r" (&v->counter), "r" (i)

:"cc");

}


给一个原子量加值,并且返回和

staticinline int atomic_add_return(int i, atomic_t *v)

{

unsignedlong tmp;

intresult;


__asm____volatile__("@ atomic_add_return\n"

"1: ldrex %0, [%2]\n"

" add %0, %0, %3\n"

" strex %1, %0, [%2]\n"

" teq %1, #0\n"

" bne 1b"

:"=&r" (result), "=&r" (tmp)

:"r" (&v->counter), "Ir" (i)

:"cc");


returnresult;

}


给原子变量减值,并且返回差

staticinline int atomic_sub_return(int i, atomic_t *v)

{

unsignedlong tmp;

intresult;


__asm____volatile__("@ atomic_sub_return\n"

"1: ldrex %0, [%2]\n"

" sub %0, %0, %3\n"

" strex %1, %0, [%2]\n"

" teq %1, #0\n"

" bne 1b"

:"=&r" (result), "=&r" (tmp)

:"r" (&v->counter), "Ir" (i)

:"cc");


returnresult;

}


比较当前的原子量的值是否等于old,如果等于则跟新为new,否则不变,返回原子变量的值

staticinline int atomic_cmpxchg(atomic_t *ptr, int old, int new)

{

unsignedlong oldval, res;


do{

__asm____volatile__("@ atomic_cmpxchg\n"

"ldrex %1, [%2]\n"

"mov %0, #0\n"

"teq %1, %3\n"

"strexeq%0, %4, [%2]\n"

:"=&r" (res), "=&r" (oldval)

:"r" (&ptr->counter), "Ir" (old), "r"(new)

:"cc");

}while (res);


returnoldval;

}

*addr&=mask

staticinline void atomic_clear_mask(unsigned long mask, unsigned long*addr)

{

unsignedlong tmp, tmp2;


__asm____volatile__("@ atomic_clear_mask\n"

"1: ldrex %0, [%2]\n"

" bic %0, %0, %3\n"

" strex %1, %0, [%2]\n"

" teq %1, #0\n"

" bne 1b"

:"=&r" (tmp), "=&r" (tmp2)

:"r" (addr), "Ir" (mask)

:"cc");

}


位原子操作:

set_bit(nr,p) *p |= 1 << nr

clear_bit(nr,p) *p &= ~(1 << nr)

change_bit(nr,p) *p ^= 1 << nr

test_and_set_bit(nr,p) *p |= 1 << nr return old

test_and_clear_bit(nr,p) *p &= ~(1 << nr) return old

test_and_change_bit(nr,p) *p ^= 1 << nr return old

find_first_zero_bit(p,sz)返回第一个为0的位号

find_next_zero_bit(p,sz,off)跨过off个位,返回第一个为0的位号

find_first_bit(p,sz)返回第一个为1的位号

find_next_bit(p,sz,off)跨过off个位,返回第一个为1的位号


二、自旋锁


1.内核抢占

内核可抢占即调度程序能够打断正在执行的内核代码去调度其他的程序,可以在makemenuconfig中的KernelFeatures--->中选择PreemptibleKernel确定内核是否可抢占


2.自旋锁


在多处理器平台上:

当一个执行线程试图获得已经被占用的自旋锁时候,这个执行线程会在其执行的处理器上忙等,直到获得锁

在单处理器平台上:

如果内核是可以抢占的,则获得锁和释放锁就相当于禁止和开启内核抢占,如果内核是不可以抢占的,则这种锁就不会编进内核


最常见的自旋锁死锁实例:

1)如果一个持有该锁的线程再次试图获得该锁,则会发生自死锁

2)如果在多处理器上,一个处理正在执行中断处理程序,并且这段代码获得了自旋锁,这时候另一个中断在本地处理器上发生了,并且也希望或得这个锁,这时候就会因为双重请求而死锁,但是如果第二个中断发生在另一个处理器上则会相安无事,所以在中断处理函数中使用自旋锁的时候要禁止本地中断。

3)如果在持有自旋锁的情况下睡眠了,会发生死锁,因为拿锁的代码睡着了,但是多个处理器上同时都来了请求这把锁的代码,这样大家就都完了


自旋锁的使用方法:

spinlock_tspin;


spin_lock_init(&spin)初始化锁,其实就是把锁设置为可获取状态

spin_lock(&spin)获得锁

spin_unlock(&spin)释放锁


spin_lock_irq(&spin)获得锁且关掉本地中断

spin_unlock_irq(&spin)释放锁且开启本地中断


spin_lock_irqsave(&spin,flag)获得锁且关掉本地中断并且保存当前的中断状态

spin_unlock_irqrestore(&spin,flag)释放锁且回复中断状态


spin_trylock(&spin)试图获得锁,如果没有获得则返回0,如果获得返回1

spin_is_locked(&spin)测试锁是否被获得,如果锁被占有则返回1,如果锁空闲返回0


3.读写自旋锁

本质上还是自旋锁,只不过是分为读锁和写锁

读锁:以只读临界区的方式获取锁,获得读锁的代码一定不要去写,这个锁可以被多个用户获取

写锁:以只写临界区的方式获取锁,只有没有读者和写者的时候这个锁才可获得

注意:如果你本来是想读,而且也获得了一个读锁,但这时候你读然想写了,顺便又获得了一个写锁,那么这样就死锁了,因为获得写锁的时候会一直等到所有的读锁全部释放,这样的话一个写的人就死等自己把读锁放掉,而产生死锁。


读写自旋锁的使用方法:

rwlock_tlock = RW_LOCK_UNLOCKED


read_lock()获取读锁

read_unlock()释放读锁


read_lock_irq()获得锁且关掉本地中断

read_unlock_irq()释放锁且开启本地中断


read_lock_irqreave()获得锁且关掉本地中断并且保存当前的中断状态

read_unlock_irqrestore()释放锁且回复中断状态


write_lock()获得锁

write_unlock()释放锁


write_lock_irq()获得锁且关掉本地中断

write_unlock_irq()释放锁且开启本地中断


write_lock_irqreave()获得锁且关掉本地中断并且保存当前的中断状态

write_unlock_irqrestore()释放锁且回复中断状态


write_trylock()试图获得锁,如果没有获得则返回非0,如果获得返回0

rw_is_locked()测试锁是否被获得,如果锁被占有则返回非0,如果锁空闲返回0


三、信号量


1.信号量

信号量允许同时能被几个人占用可以在声明信号量的时候制定,但一般是指定一个,这时候这个信号量就叫做互斥信号量或者二值信号量,如果制定可以被多个人占用这时候这个信号量就叫做计数信号量

常见的死锁实例:

1)在不可睡眠的代码中使用信号量,比如中断上下文

2)已经获得自旋锁的代码,如果再使用信号量去睡眠的话,那么就会发生死锁

信号量的使用方法:

structsemaphore {

spinlock_t lock;

unsignedint count;

structlist_head wait_list;

};信号量的类型结构体


#define__SEMAPHORE_INITIALIZER(name, n) \

{ \

.lock = __SPIN_LOCK_UNLOCKED((name).lock), \

.count = n, \

.wait_list = LIST_HEAD_INIT((name).wait_list), \

}原始的初始化信号量方法

静态声明一个互斥信号量

#defineDECLARE_MUTEX(name) \

structsemaphore name = __SEMAPHORE_INITIALIZER(name, 1)

动态的初始化一个信号量

staticinline void sema_init(struct semaphore *sem, int val)

{

staticstruct lock_class_key __key;

*sem= (struct semaphore) __SEMAPHORE_INITIALIZER(*sem, val);

lockdep_init_map(&sem->lock.dep_map,"semaphore->lock", &__key, 0);

}

动态的初始化信号量的两个宏定义

#defineinit_MUTEX(sem) sema_init(sem,1)信号量开始可用

#defineinit_MUTEX_LOCKED(sem) sema_init(sem,0)信号量开始不可用


获取信号量:

1)获取时如果睡眠则信号不能打断该睡眠

voiddown(struct semaphore *sem)

{

unsignedlong flags;

spin_lock_irqsave(&sem->lock,flags);

if(likely(sem->count > 0))

sem->count--;

else

__down(sem);//对进程设置为TASK_UNINTERRUPTIBLE状态,并把进程放到sem的等待对列中

spin_unlock_irqrestore(&sem->lock,flags);

}

2)获取时如果睡眠则信号可以打断,如果在睡眠接到信号则返回-EINTR

intdown_interruptible(struct semaphore *sem)

{

unsignedlong flags;

intresult = 0;

spin_lock_irqsave(&sem->lock,flags);

if(likely(sem->count > 0))

sem->count--;

else

result=__down_interruptible(sem);//对进程设置为TASK_INTERRUPTIBLE状态

spin_unlock_irqrestore(&sem->lock,flags);

returnresult;

}

3)如果获取时候信号量没有了则立即返回1,否则返回0

intdown_trylock(struct semaphore *sem)

{

unsignedlong flags;

intcount;

spin_lock_irqsave(&sem->lock,flags);

count= sem->count - 1;

if(likely(count >= 0))

sem->count= count;

spin_unlock_irqrestore(&sem->lock,flags);

return(count < 0);

}

释放信号量:

voidup(struct semaphore *sem)

{

unsignedlong flags;

spin_lock_irqsave(&sem->lock,flags);

if(likely(list_empty(&sem->wait_list)))

sem->count++;//假如等待连表为空,那么信号量就++

else

__up(sem);//如果等待队列上有进程在等待,则把改进成拿下来执行

spin_unlock_irqrestore(&sem->lock,flags);

}


2.读写信号量

本质上还是信号量,但是分为读信号量和写信号量

读信号量:以读临界区的方式拿到信号量

写信号量:以写临界区的方式拿到信号量

注意:读信号量可以多个人同时候所有,但写信号量在没有读者且只能一个人所有


读写信号量的使用:

structrw_semaphore {

__s32 activity;

spinlock_t wait_lock;

structlist_head wait_list;

};

#define__RWSEM_INITIALIZER(name) \

{0, __SPIN_LOCK_UNLOCKED(name.wait_lock), \

LIST_HEAD_INIT((name).wait_list) \

__RWSEM_DEP_MAP_INIT(name)}

静态声明一个信号量

#defineDECLARE_RWSEM(name) \

structrw_semaphore name = __RWSEM_INITIALIZER(name)

动态初始化一个信号量

#defineinit_rwsem(sem) \

do{ \

staticstruct lock_class_key __key; \

\

__init_rwsem((sem),#sem, &__key); \

}while (0)


获取读信号量

down_read(structrw_semaphore *sem)

释放读信号量

up_read(structrw_semaphore *sem)

获取写信号量

down_write(structrw_semaphore *sem)

释放写信号量

up_write(structrw_semaphore *sem)


四、完成量

1.完成量

完成量就是一个线程完事之后唤醒另一个等待该完成量的线程或者所有等待该完成量的线程

2.完成量的使用

静态声明一个完成量

DECLARE_COMPLETION(NAME)

动态初始化一个完成量

init_completion(structcompletion *x)

等待完成通知,不能被CTRL+ C打断,也不能被kill

wait_for_completion(structcompletion *x)

等待完成通知,能被CTRL+ C打断,也能被kill掉,如果被打断或者kill则返回负数,否则为0

intwait_for_completion_interruptible(struct completion *x);

等待完成通知,能被CTRL+ C打断,也能被kill掉,如果被打断或者kill则返回负数,否则为0

intwait_for_completion_killable(struct completion *x);

等待完成通知,不能被CTRL+ C打断,也不能被kill掉,但是时间一到自己就醒了,时间是按照HZ来记述的,时间到返回0,被唤醒返回返回剩余时间

longwait_for_completion_timeout(struct completion *x, unsigned longtimeout);

等待完成通知,能被CTRL+ C打断,也能被kill掉,但是时间一到自己就醒了,时间是按照HZ来记述的,时间到返回0,被唤醒返回返回剩余时间

longwait_for_completion_interruptible_timeout(struct completion *x,unsigned long timeout);

试图获得完成量,如果获得返回1,如果未获得则返回0,但不会睡觉

booltry_wait_for_completion(struct completion *x);

检测是否完成过,如果完成过且没有人等待过则返回1,否则0

boolcompletion_done(struct completion *x);

把所有等待该完成量的线程唤醒,一旦唤醒后以后就不再会等待了,这个是告诉所有的线程,即使没有等待的线程以后即使调用wait_for_completion也不会睡眠

complete_all(structcompletion *x)

只唤醒一个线程

complete(structcompletion *x);


五、seqlock

一、seq

2.6内核新加入的一种锁机制,可以被多个读者同时拥有,只能有一个写者,写者和读者可以同时存在,在seq锁里边定义了一个sequence变量拥于记录写者和读者之间的关系,如果读者读完后发现seq锁当中的sequence是奇数,则说明在读得过程中写者来过,所以应当重新读,其实读者在读之前就会检查sequence的奇偶性,如果是奇数就重新获得sequence的值,直到是偶数为止,读者本身不改变sequence的值。写者在获得seq锁和释放seq锁的时候都会把sequence1sequence的初始值是0,所以只要是写完了,sequence的值一定是偶数


typedefstruct {

unsignedsequence;//用于表示写者是否在的变量

spinlock_tlock;//用于锁住写者的自旋锁

}seqlock_t;


动态初始化一个seq锁,需要传入该锁的指针

#defineseqlock_init(x) \

do{ \

(x)->sequence= 0; \

spin_lock_init(&(x)->lock); \

}while (0)


获得写锁,如果已被别人获取则自旋

staticinline void write_seqlock(seqlock_t *sl)

{

spin_lock(&sl->lock);

++sl->sequence;

smp_wmb();

}

释放写锁,释放自旋锁

staticinline void write_sequnlock(seqlock_t *sl)

{

smp_wmb();

sl->sequence++;

spin_unlock(&sl->lock);

}

试图获得seq锁,如果获得返回1,否则返回0

staticinline int write_tryseqlock(seqlock_t *sl)

{

intret = spin_trylock(&sl->lock);

if(ret) {

++sl->sequence;

smp_wmb();

}

returnret;

}

获得读锁

static__always_inline unsigned read_seqbegin(const seqlock_t *sl)

{

unsignedret;

repeat:

ret= sl->sequence;

smp_rmb();

if(unlikely(ret & 1)) {//检查奇偶性,如果是奇数则重新获取

cpu_relax();

gotorepeat;

}

returnret;

}

读完后检查在读的过程中是否有写者来过

static__always_inline int read_seqretry(const seqlock_t *sl, unsignedstart)

{

smp_rmb();

return(sl->sequence != start);

}



信号量(Semaphore)

信号量和互斥锁

  Kernel提供不同的原语来处理不同的情况,最常用的是采用信号量的方式。如果不能获得资源将进入sleep状态,等待资源释放,也即block的方式。通过加锁的原语使之sleep,例如在scull的write()的例子中,kmalloc很适合,但是不是所有情况都适合sleep的方式。信号量是一种sleep机制。它包含一个整数,以及一对函数P和V。进程如果需要进入将调用P,如果信号量的值大于0,那么该值减一,继续执行,如果信号量等于或者小于0,进程将等待其他人释放型号了。Unlock信号量即调用V,它将信号量的值加1,如果可能唤醒正在等待的进程。

  如果信号量用户互斥(mutex:mutual exclusion),将信号量的值初始化的1,这样只允许一个进程或者线程执行。这种情况下,信号量也成为互斥锁。在linux kernel中基本上是由于的信号量都是互斥锁。

  在scull中,我们已经以互斥锁的方式使用过信号量,在scull_write()中有竞争导致的内存泄漏的危险:

if(!dptr->data[s_pos]){
        dptr->data[s_pos] = kmalloc(quantum, GFP_KERNEL);
        if(dptr->data[s_pos] == NULL)
               goto out;
        … …
}

  这里分配了内存空间kmalloc,然后进行copy内容,如果在执行的过程中,有多个进程同时触发了scull_write,就发生竞争,例如A执行第一句判断,当尚未执行第二句kmalloc时,B执行第一句,因此B也需要执行第二句,因此两个进程对data[s_pos]都需要kmalloc空间,并执行copy内容,导致内容混乱是一个方面,A分配的空间,他的指针消失了,我们无法对A的kmalloc进行释放,它将占据内存的一个空间,直至系统关闭,这就是内存泄漏。

  信号量的使用例子见kernel module编程(五):设备读写,相关操作已经重点标出,这里不再重复。使用方式如下:

  1. 头文件加载:#include <asm/semaphore.h>,相关的数据结构为struct semaphore
  2. 创建信号量:void sema_init(struct semaphore * sem, int val); 当val设置为1时就成为互斥锁。对于互斥锁,也可以采用如下的方式:
    1. 声明和初始化一个互斥锁:DECLARE_MUTEX(name)或者DECLARE_MUTEX_LOCKED(name),其中name是一个struct semaphore的变量。 在后一种方式,起始状态就是locked,其他线程/进程需要等待其unlocked才能进入。
    2. 如果一个互斥锁需要实时初始化,即动态产生,使用:void int_MUTEX(struct semaphore * sem);或者void init_MUTEX_LOCKED(struct semaphore *sem);
  3. 对于信号量获取,即P函数,通过down来调用,对信号量进行减一操作,如果信号量不满足要求,则将调用者置为sleep状态直至资源获取。方式如下:
    1. void down(struct semaphore * sem); 对信号量减一,并一直等到资源获取。
    2. int down_interruptible(struct semaphore *sem);同上,但是采用中断的方式(interruptible),这是最为常用的使用方式,它允许用户中断一个正在等待信号量获取的用户空间的进程。如果采用非中断方式,则这个这个期间,进程是无法killed的。我们需要注意的是,采用这种方式,如果在等待期间用户终止了进程,则将返回一个非零的值,所以必须判断返回值,并作出相应处理。一般返回为-ERESTARTSYS,在返回之前,应当确保undo之前用户可察觉的所有操作,如果不能确保,返回-EINTR.
    3. int down_trylock(struct semaphore * sem); 这种方式不会sleep,如果不能成功会的信号量,将马上返回非零值。
  4. 当一个线程成功调用down,即获得的semahore,将进入这段敏感代码区,执行完后,必须释放信号量。通过up来调用V操作。void up(struct semaphre * sem)。我们要非常注意,如果在处理过程中出现异常或者错误而需要return,必须要保证信号量的释放,即无论是正常的还是异常地离开这段敏感代码区,都必须释放信号量。否则任何线程/进程将无法获取信号量。

  我们需要注意,一定要在获取信号量之前确保已经初始化。在语句先后执行顺序中必须要优先处理。

读写信号量

  scull的例子中,读和写都通过信号量进行保护,防止一起写,也防止在写和读同时操作,这些都是我们应当避免的,当时它同时也不允许两个读的操作一通进行,而这种情况是不会产生危害的。Linuxkernel提供了一个rwsem的特别的信号量用于处理这种情况,允许多个读同时存在以提高程序处理能力。读写信号量在驱动中一般较少使用,但是有时会非常有效。使用方式如下:

  • 头文件加载:#include <linux/rwsem.h>,相应的的数据结构为struct rw_semaphore
  • 初始化操作:void init_rw_sem(struct rw_semaphore * sem);
  • 对于只读操作,相关函数如下:
    • void down_read(struct rw_semaphore * sem); 提供只读获取的保护,可以同时有其他的只读操作。他将置调用者与非中断的sleep,这是需要特别注意的,即如果另外有写的操作,而引起sleep,是非中断,用户不能在此刻中断进程。
    • int down_read_trylock(struct rw_semaphore * sem); 马上返回,如果可读,返回非零,不可读,返回0。注意这个返回和一般的kernel函数的方式方式不一样。也可信号量的返回方式不一样。
    • void up_read(struct rw_semaphore * sem); 释放读写信号量。
  • 对于写的保护,相关函数如下:
    • void down_write(struct rw_semaphore * sem); 类似down_read
    • int down_write_trylock(struct rw_semaphore * sem); 类似down_read_trylock
    • void up_write(struct rw_semaphore * sem); 类似up_read
    • void downgrade_write(struct rw_semaphore * sem); 当一个写保护后,跟着一个耗费时间长的读保护,我们可以在使用能够downgrade_write,它允许其他读操作在你结束写后,马上获得读写型号了。否侧通常的处理是需要等待这个紧跟的漫长的读操作。

  读写信号量允许一个写用户或者无限个读用户来获得,写用户将具备优先具备,当一个写试图进入这段关键操作代码时,其他读者都无法获得信号量,必须等待所有的写完成。因此它适合于写操作比较少,且写的过程比较短的情况,不适合存在大量写,这会阻碍读的处理。

completion(不知道中文名字,可能是完成量,^_^)

  一般信号量的的处理会限制在一个函数内,但是有时会函数A的处理的前提条件是函数B,A必须等待B处理后才能继续,可以用信号量来进行处理,但linux kernel提供complete的方式。使用方式如下:

  • 头文件#include ,数据结构为struct completion,初始化为init_completion(struct completion *comp),也可以直接使用DECLARE_COMPLETION(comp);
  • 在A函数中,如果需要等待其他的处理,使用void wait_for_completion(struct completion *comp);则在这个位置上将处于非中断的sleep,进行等待,也就是相关的线程/进程,用户是无法kill的。
  • 在B函数,如果已经处理完,可以交由A函数处理,有下面两种方式
    • void complete(struct completion * comp);如果要执行A必须等待B先执行,B执行后,A可以继续执行。如果A需要再次执行,则需要确保下一次B执行完。如果连续执行两次B,则可以执行两次A,第三次A要等第三次B执行完。
    • void complete_all(struct completion * comp);只要B执行完,A就可以执行,无论执行多少次。如果需要再等待B的直系个可以使用INIT_COMPLETION(struct completion *comp)。重新初始化completion即可。
    • void complete_and_exit(struct completion * comp,longretval); 这个处理具有complete的功能外,还将调用它的线程/进程终止。可用于一些无限循环的场景,例如受到某个cleaned up的信息后,e通知用户程序终止,允许A函数执行。

  针对我们的例子Scull,其实completion并不是合适的场景,但我们可以通过它来试验一下。我们希望是在scull的读操作之前都先完成一次写操作。

#include <linux/completion.h>
... ...
DECLARE_COMPLETION(comp); //为了试验方便,就不分每个scull都持有一个完成量,本来应当如此

int scull_read(... ...){
    struct scull_qset * dptr;
    ... ...

    printk("scull_read waiting for completed from write function./n");
    wait_for_completion(&comp);
    printk("scull_read awake for reading,continue ..../n");
    ... ....
}

int scull_write(... ...){
    ... ...

    complete(&comp);
// complete_all(&comp);
// complete_and_exit(&comp);
}

  对于scull0,我们原来在用户空间有一个读写测试例子,将其分为读测试和写测试。当我们调用读测试是,例子sleep,只有调用写测试时,读测试才能继续进行。但是发现,scull_read的使用发现scull的内核模块发生crash,这是因为在wait_for_completion()之前,对一些变量进行赋值,例如dptr,而这些变量在write的时候是发现改变的,因此出现错误,需要在wait_for_completion后面对这些变量进行赋值。这样解决了crash的问题。但是我们发现在写测试后,读测试可以继续进行,但是很快又陷入了等待状态。下面是读测试的有关代码:

file = fopen("dev/scull0","r");
... ...

while((len = fread(read_buf,1,512,file)) > 0){
    total_len +=len;
    printf("%s",read_buf);
    memset(read_buf,0,512);
}

  会调用scull_read直至scull_read返回0或者<0为止。因为已经进行了写操作,所以第一次调用返回内容长度,会出现第二次调用。这样会将程序搞得很混乱,实际上我们写测试,因为写的内容少,可以一次写完,也很可能分为多次写。所以在这里加完成量是不合适的,可以在fopen中进行处理,即scull_open中进行处理,例如:

int scull_open(....)
{
    ... ...
    if((filp->f_flags & O_ACCMODE) == O_RDONLY){
        printk("waiting for completed from write function./n");

        wait_for_completion(&comp);
        printk("awake for reading,continue ..../n");
    }
    ... ...
}

  这是更为合理的方式。如果complete的位置仍然放在scull_write中,我们试验三种方式。complete_and_exit(),可以要求写测试中写入大量的内容,我们将发现只写了部分的内容(第一次调用scull_write),测试程序就退出。scull并不是个合适的completion的例子,completion可能会引起这样或者那样的问题,需要仔细规划。

相关链接:

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值