Linux内核设计与实现——内核同步方法

内核同步

1. 原子操作

内核提供了两组原子操作接口——一组针对整数进行操作,另一组针对单独的位进行操作。有些体系结构缺少简单的原子操作指令,但也为单步执行提供了锁内存总线的指令,确保其他改变内存的操作不能同时发生。

(1) 原子整数操作

  1. 针对整数的原子操作只能对atomic_t类型的数据进行处理。之所以引入一个特殊数据类型,主要是因为:首先,让原子函数只接收atomic_t类型的操作数,确保原子操作只与这种特殊类型数据一起使用,同时也保证了该类型的数据不会被传递给非原子函数;其次,使用atomic_t类型确保编译器不对相应的值进行访问优化——使得原子操作最终接收到正确的内存地址;最后,在不同体系结构上实现原子操作的时候,使用atomic_t可以屏蔽其间的差异。atomic_t类型定义在<linux/types.h>中:
typedef struct {
		volatile int counter;
} atomic_t;
  1. 定义一个atomic_t类型的数据,还可以在定义时给它设定初值:
atomic_t v;					  /* 定义v */
atomic_t u = ATOMIC_INIT(0);  /* 定义u并把它初始化为0 */
  1. 使用原子整型操作需要的声明都在<asm/atomic.h>中。表10-1列出了所有的标准原子整数操作。有些体系结构会提供一些只能在该体系结构上使用的额外原子操作方法,也可以在<asm/atomic.h>中找到。
    在这里插入图片描述
  2. 原子操作通常是内联函数,往往是通过内嵌汇编指令来实现的。如果某个函数本来就是原子的,那它往往会被定义成一个宏。例如,在大部分体系结构上,读取一个字本身就是一种原子操作,所以在对一个字进行写入操作期间不可能完成对该字的读取。把atomic_read()定义成一个宏,返回atomic_t类型的整数值:
/**
 * atomic_read - read atomic variable
 * @v: pointer of type atomic_t
 *
 * Atomically reads the value of @v.
 */
static inline int atomic_read(const atomic_t *v)
{
		return v->counter;
}

(2) 64位原子操作

  1. atomic_t一样,atomic64_t类型其实是对长整型的一个简单封装类:
typedef struct {
		volatile long counter;
} atomic64_t;
  1. 表10-2是所有标准原子操作列表。
    在这里插入图片描述
    (3) 原子位操作

  2. 内核提供了一组针对位这一级数据进行操作的函数,它们是与体系结构相关的操作,定义在<asm/bitops.h> 中。

  3. 位操作函数是对普通的内存地址进行操作的,所以没有特殊的数据类型。它的参数是一个指针和一个位号,第0位是给定地址的最低有效位。在32位机上,第31位是给定地址的最高有效位,而第32位是下一个字的最低有效位。

  4. 在表10-3中给出了标准原子位操作列表。可以把原子位指令与一般的C语句混在一起。
    在这里插入图片描述

  5. 内核还提供了一组与上述操作对应的非原子位函数。非原子位函数与原子位函数的操作完全相同,其名字前缀多两个下划线。例如,与test_bit()对应的非原子形式是__test_bit()。如果不需要原子性操作(比如已经用锁保护了数据),那么这些非原子的位函数相比原子的位函数可能会执行得更快些。

  6. 内核还提供了两个例程用来从指定的地址开始搜索第一个被设置(或未被设置)的位:

int find_first_bit(unsigned long *addr, unsigned int size)
int find_first_zero_bit(unsigned long *addr, unsigned int size)

​ 这两个函数中第一个参数是一个指针,第二个参数是要搜索的总位数,返回值分别是第一个被设置的(或没被设置的)位的位号。如果 搜索范围仅限于一个字,使用_ffs()_ffz()更好,只需要给定一个要搜索的地址做参数。

2. 自旋锁

(1) 自旋锁方法

  1. 自旋锁的实现和体系结构密切相关,代码往往通过汇编实现。与体系结构相关的代码定义在<asm/spinlock.h>中,实际用到的接口定义在<linux/spinlock.h>中。自旋锁的基本使用形式如下:
DEFINE_SPINLOCK(mr_1ock);
spin_lock(&mr_lock);
/*临界区...*/
spin_unlock(&mr_lock);
  1. 自旋锁可以使用在中断处理程序中(此处不能使用信号量,因为它们会导致睡眠)。在中断处理程序中使用自旋锁时,要在获取锁之前先禁止本地中断(在当前处理器上的中断请求)。需要关闭的只是当前处理器上的中断。如果中断发生在不同的处理器上,即使中断处理程序在同一锁上自旋,也不会妨碍锁的持有者(在不同处理器上)最终释放锁。

  2. 内核提供的禁止中断同时请求锁的接口,方法如下:

DEFINE_SPINLOCK(mr_lock);
unsigned long flags;

spin_lock_irqsave(&mr_lock, flags);
/*临界区...*/
spin_unlock_irqrestore(&mr_lock, flags);

spin_lock_irqsave()保存中断的当前状态,并禁止本地中断,然后获取指定的锁;spin_unlock_irqrestore()对指定的锁解 锁,然后让中断恢复到加锁前的状态。所以即使中断最初是被禁止的,代码也不会错误地激活它们,会继续让它们禁止。flags变量 看起来像是由数值传递的,这是因为这些锁函数有些部分是通过宏的方式实现的。

  1. 在单处理器系统上,虽然在编译时抛弃掉了锁机制,但在上面例子中仍需要关闭中断,以禁止中断处理程序访问共享数据。加锁和解锁分别可以禁止和允许内核抢占。如果禁止内核抢占,那么在编译时自旋锁会被完全剔除出内核。

  2. 如果能确定中断在加锁前是激活的,就不需要在解锁后恢复中断以前的状态了,可以无条件地在解锁时激活中断。这时,使用spin_lock_irq()spin_unlock_irq()更好一些:

DEFINE_SPINLOCK(mr_lock);

spin_lock_irq(&mr_lock);
/* 关键节 */
spin_unlock_irq(&mr_lock);
  1. 调试自旋锁

    配置选项CONFIG_DEBUG_SPINLOCK为使用自旋锁的代码加入了许多调试检测手段。例如,检查是否使用了未初始化的锁,是否在还没加锁的时候就要对锁执行开锁操作。如果需要进一步全程调试锁,还应打开CONFIG_DEBUG_LOCK_ALLOC选项。
    在这里插入图片描述
    (2) 自旋锁和下半部

  2. 在与下半部配合使用时,必须小心地使用锁机制。函数spin_lock_bh()用于获取指定锁,同时禁止所有下半部的执行;spin_unlock_bh()函数执行相反的操作。

  3. 由于下半部可以抢占进程上下文中的代码,所以当下半部和进程上下文共享数据时,必须对进程上下文中的共享数据进行保护,所以需要加锁的同时还要禁止下半部执行;由于中断处理程序可以抢占下半部,所以如果中断处理程序和下半部共享数据,那么在获取恰当的锁的同时还要禁止中断。

  4. 当数据被两个不同种类的tasklet共享时,就需要在访问下半部中的数据前先获得一个普通的自旋锁。这里不需要禁止下半部,因为在同一个处理器上不会有tasklet相互强占的情况。对于软中断,也没必要禁止下半部。

3. 读-写自旋锁

  1. 有时锁的用途可以明确地分为读取和写入两个场景。例如,对一个链表可能既要更新又要检索。更新(写入)链表时,不能有其他代码并发地写链表或从链表中读取数据,写操作要求完全互斥;当对其检索(读取)链表时,只要其他程序不对链表进行写操作就行了。只要没有写操作,多个并发的读操作都是安全的。

  2. 一个或多个读任务可以并发地持有读者锁;用于写的锁最多只能被一个写任务持有,而且不能有并发的读操作。有时把读/写锁叫做共享/排斥锁,或者并发/排斥锁,因为这种锁以共享(对读者而言)和排斥(对写者而言)的形式获得使用。

  3. 读/写自旋锁的使用方法类似于普通自旋锁,它们通过下面的方法初始化:

DEPINE_RWLOCK(mr_rwlock)

​ 在读者的代码分支中使用如下函数:

read_lock(&mr_rwlock);
/*临界区(只读)...*/
read_unlock(&mr_rwlock);

​ 在写者的代码分支中使用如下函数:

write_lock(&mr_rwlock);
/*临界区(读写)...*/
write_unlock(&mr_rwlock);
  1. 不能把一个读锁“升级”为写锁,比如:
read_lock(&mr_rwlock);
write_lock(&mT_rwlock);

​ 执行上述代码会带来死锁,所以需要写操作时,要在一开始就请求写锁。如果写和读不能清晰分开,那么就使用一般的自旋锁。

  1. 即使一个线程递归地获得同一读锁也是安全的。如果在中断处理程序中只有读操作而没有写操作,那么就可以混合使用“中断禁止”锁,使用read_lock()而不是read_lock_irqsave()对读进行保护。不过,还是需要用write_lock_irqsave()禁止有写操作的中断,否则,中断里的读操作就有可能锁死在写锁上。表10-5列出了针对读一写自旋锁的所有操作。
    在这里插入图片描述

4. 信号量

  1. Linux中的信号量是一种睡眠锁。如果有一个任务试图获得一个不可用(已被占用)的信号量时,信号量会将其推进一个等待队列,让其睡眠,处理器执行其他代码。当持有的信号量可用(被释放)后,处于等待队列中的那个任务将被唤醒,并获得该信号量。

  2. 从信号量的睡眠特性得出一些结论:

    a. 由于执行线程在锁被争用时会睡眠,所以只能在进程上下文中才能获取信号量锁,在中断上下文中是不能进行调度的。

    b. 可以在持有信号量时睡眠(也可能不需要睡眠),其他进程试图获得同一信号量时不会因此而死锁(该进程也只是去睡眠而已)。

    c. 在占用信号量时不能占用自旋锁。因为等待信号量时可能会睡眠,而在持有自旋锁时不允许睡眠。

  3. 往往在需要和用户空间同步时,代码会需要睡眠,此时使用信号量是唯一的选择。由于不受睡眠的限制,使用信号量通常更加容易。在自旋锁和信号量中做选择,应该根据锁被持有的时间长短做判断。信号量不会禁止内核抢占,持有信号量的代码可以被抢占,意味着信号量不会对调度的等待时间带来负面影响。

(1) 计数信号量和二值信号量

  1. 信号量可以同时允许任意数量的锁持有者,该数量可以在声明信号量时指定,称为使用者数量或数量。 通常情况下,信号量在一个时刻仅允许有一个锁持有者,这时计数等于1,称为二值信号量或者称为互斥信号量。初始化时也可以把数量设置为大于1的非0值,称为计数信号量,它允许在一个时刻至多有count个锁持有者。

  2. down()操作通过对信号量计数减1来请求获得一个信号量。如果结果是0或大于0,获得信号量锁,任务可以进入临界区;如果是负数,任务会被放入等待队列,处理器执行其他任务。当临界区中的操作完成后,up()操作用来释放信号量,也称作提升信号量,因为它会增加信号量的计数值。如果在该信号量上的等待队列不为空,那么处于队列中等待的任务在被唤醒时会获得该信号量。

(2) 创建和初始化信号量

  1. 信号量的实现与体系结构相关,具体实现定义在<asm/semaphore.h>中。struct semaphore类型表示信号量。可以通过以下方式静态地声明信号量——其中name是信号量变量名,count是信号量的使用数量:
struct semaphore name;
sema_init(&name, count);
  1. 创建更为普通的互斥信号量可以使用以下快捷方式:
static DECLARE_MUTEX(name);
  1. 动态创建信号量,此时只有指向该动态创建的信号量的间接指针,可以使用如下函数进行初始化:
sema_init(sem, count);
  1. 初始化一个动态创建的互斥信号量时使用如下函数:
init_MUTEX(sem);

(3) 使用信号量

  1. 函数down_interruptible()试图获取指定的信号量,如果信号量不可用,把调用进程置成TASK_INTERRUPTIBLE状态——进入睡眠,意味着任务可以被信号唤醒。如果进程在等待获取信号量时接收到了信号,该进程就会被唤醒,函数down_interruptible()返回-EINTR。另一个函数down()会让进程在TASK_UNINTERRUPTIBLE状态下睡眠。这样,进程在等待信号量时就不再响应信号了。

  2. 要释放指定的信号量,需要调用up()函数。例如:

/* 定义并声明一个信号量,名字为mr_sem, 用于信号量计数 */
static DECLARE_MUTEX(mr_sem);

/* 试图获取信号量 ... */
if (down_interruptible(&mr_sem)) {
		/* 信号被接收,信号量还未获取 */
}

/* 临界区... */

/* 释放给定的信号量 */
up(&mr_sem);
  1. 表10-6给出了针对信号量的方法的完整列表。
    在这里插入图片描述

5. 读-写信号量

  1. 读-写信号量在内核中是由rw_semaphore结构表示的,定义在<linux/rwsem.h>中。通过以下语句可以创建静态声明的读-写信号量:
static DECLARE_RWSEM(name);

​ 动态创建的读-写信号量可以通过以下函数初始化:

init_rwsem(struct rw_semaphore *sem) 
  1. 所有的读-写信号量都是互斥信号量——也就是它们的引用计数等于1,虽然它们只对写者互斥,不对读者。只要没有写者,并发持有读锁的读者数不限。只有唯一的写者(在没有读者时)可以获得写锁。所有读-写锁的睡眠都不会被信号打断,所以它只有一个版本的down()操作。例如:
static DECLARE_RWSEM(mr_rwsem);

/* 试图获取信号量用于读 ... */
down_read(&mr_rwsem);

/* 临界区 (只读)... */

/* 释放信号量 */
up_read(&mr_rwsem);
/* ... */

/* 试图获取信号量用于写 ... */
down_write(&mr_rwsem);

/* 临界区 (读和写)... */

/* 释放信号量 */
up_write(&mr_sem);
  1. 读-写信号量也提供了down_read_trylock()down_write_trylock()方法,都需要一个指向读-写信号量的指针作为参数。如果成功获得了信号量锁,返回非0值;如果信号量锁被争用,则返回0。这与普通信号量的情形完全相反。

  2. 读-写信号量相比读-写自旋锁多一种特有的操作:downgrade_write()。这个函数可以动态地将获取的写锁转换为读锁。

6. 互斥体

  1. “互斥体(mutex)”用于一种实现互斥的特定睡眠锁。mutex在内核中对应数据结构mutex,其行为和使用计数为1的信号量类似,但操作接口更简单,实现更高效,且使用限制更强。静态地定义mutex:
DEFINE_MUTEX(name);

​ 动态初始化mutex:

mutex_init(&mutex);

​ 对互斥锁锁定和解锁并不难:

mutex_lock(&mutex);
/* 临界区 */
mutex_unlock(&mutex);
  1. 表10-7是基本的mutex操作列表。
    在这里插入图片描述

  2. mutex的使用场景相对而言更严格、更定向:

    a. 给mutex上锁者必须负责给其再解锁——不能在一个上下文中锁定一个mutex, 而在另一个上下文中给它解锁。这个限制使得mutex不适合内核同用户空间复杂的同步场景。最常使用的方式是:在同一上下文中上锁和解锁。

    b. 递归地上锁和解锁是不允许的。不能递归地持有同一个锁,也不能再去解锁一个已经被解开的mutex。

    c. 当持有一个mutex时,进程不可以退出。

    d. mutex不能在中断或者下半部中使用,即使使用mutex_trylock()也不行。

    e. mutex只能通过官方API管理:只能使用上节中描述的方法初始化,不可被拷贝、手动初始化或者重复初始化。

    打开内核配置选项CONFIG_DEBUG_MUTEXES后,就会有多种检测来确保这些(还有别的)约束得以遵守。

7. 完成变量

  1. 如果在内核中一个任务需要发出信号通知另一任务发生了某个特定事件,利用完成变量是使两个任务得以同步的简单方法。如果一个任务要执行一些工作时,另一个任务就会在完成变量上等待。当这个任务完成工作后,会使用完成变量去唤醒在等待的任务。例如,当子进程执行或者退出时,vfork()系统调用使用完成变量唤醒父进程。

  2. 完成变量由结构completion表示,定义在<linux/completion.h>中。通过以下宏静态地创建完成变量并初始化它:

DECLARE_COMPLETION(mr_comp);

​ 通过init_completion()动态创建并初始化完成变量。

  1. 在一个指定的完成变量上,需要等待的任务调用wait_for_completion()来等待特定事件。当特定事件发生后,产生事件的任务调用complete()来发送信号唤醒正在等待的任务。表10-9列出了完成变量的方法。
    在这里插入图片描述
  2. 使用完成变量的例子可以参考kernel/sched.c和kernel/fork.c。完成变量的通常用法是,将完成变量作为数据结构中的一项动态创建,而完成数据结构初始化工作的内核代码将调用wait_for_completion()进行等待。初始化完成后,初始化函数调用completion()唤醒在等待的内核任务。

8. BLK:大内核锁

  1. BKL(大内核锁)是一个全局自旋锁:

    a. 持有BKL的任务仍然可以睡眠。当任务无法被调度时,所加锁会自动被丢弃;当任务被调度时,锁又会被重新获得。并不是当任务 持有BKL时睡眠是安全的,仅仅是可以这样做,因为睡眠不会造成任务死锁。

    b. BKL是一种递归锁。一个进程可以多次请求一个锁,并不会像自旋锁那样产生死锁现象。

    c. BKL只可以用在进程上下文中。和自旋锁不同,不能在中断上下文中申请BLK。

    d. 新的用户不允许使用BLK。

  2. 新的内核代码中不再使用BKL,但这种锁仍在部分内核代码中得到沿用。BKL的使用方式和自旋锁类似。函数lock_kernel()请求锁,unlock_kernel()释放锁。一个执行线程可以递归的请求锁,但是,在最后一个解锁操作完成后,锁才会被释放。函数kernel_locked()检测锁当前是否被持有,如果被持有,返回一个非0值,否则返回0。这些接口被声明在<linux/smp_lock.h>中,简单的用法如下:

lock_kernel();
/* 临界区,对所有其他的BLK用户进行同步.....
 * 可以安全地在此睡眠,锁会悄无声息地被释放
 * 任务被重新调度时,锁又会被悄无声息地获取
 * 意味着不会处于死锁状态,但是,如果需要锁保护这里的数据
 * 还是不需要睡眠
 */
unlock_kernel();
  1. BKL在被持有时同样会禁止内核抢占。在单一处理器内核中,BKL不执行实际的加锁操作。

9. 顺序锁

  1. 顺序锁,简称seq锁,提供了一种机制用于读写共享数据。实现这种锁主要依靠一个序列计数器。当有疑义的数据被写入时,会得到一个锁,并且序列值会增加。在读取数据之前和之后,序列号都被读取。如果读取的序列号值相同,说明在读操作进行的过程中没有被写操作打断过。如果读取的值是偶数,表明写操作没有发生(因为锁的初值是0,所以写锁会使值成奇数,释放时变成偶数)。

  2. 定义一个seq锁:

seqlock_t mr_seq_lock = DEFINE_SEQLOCK(mr_seq_lock);

​ 写锁和普通自旋锁类似,方法如下:

write_seqlock(&mr_seq_lock);
/*写锁被获取 ...*/
write_sequnlock(&mr_seq_lock);

​ 读时与自旋锁有很大不同:

unsigned long seq;
do{
        seq = read_seqbegin(&mr_seq_lock);
        /*读这里的数据 ...*/
}while(read_seqretry(&mr_seq_lock, seq));
  1. seq锁对写者更有利,只要没有其他写者,写锁总是能够被成功获得。读者不会影响写锁。挂起的写者会不断地使得读操作循环,直到不再有任何写者持有锁为止。

  2. seq锁在以下需求中是最理想的选择:

    a. 数据存在很多读者。

    b. 数据写者很少。

    c. 希望写优先于读,而且不允许读者让写者饥饿。

    d. 数据很简单,如简单结构,甚至是简单的整型——在某些场合是不能使用原子量的。

  3. jiffies是使用一个64位的变量,记录了自系统启动以来的时钟节拍累加数。对于能自动读取全部64位jffies_64变量的机器来说,需要用get_jiffies_64()方法完成,而该方法的实现就是用了seq锁:

u64 get_jiffies_64(void)
{
        unsigned long seq;
        u64 ret;
    
        do {
                seq = read_seqbegin(&xtime_1ock);
                ret = jiffies_64;
        } while (read_seqretry(&xtime_lock, seq));
        return ret;
}

​ 定时器中断会更新jiffies的值,也需要使用seq锁变量:

write_seqlock(&xtime_lock);
jiffies_64 += 1;
write_sequnlock(&xtime_lock);

10. 禁止抢占

  1. 内核抢占代码使用自旋锁作为非抢占区域的标记。如果一个自旋锁被持有,内核便不能进行抢占。因为内核抢占和SMP面对相同的并发问题,并且内核已经是SMP安全的,所以内核也是抢占安全的。

  2. 某些情况并不需要自旋锁,但是仍然需要关闭内核抢占。最频繁出现的情况就是每个处理器上的数据。如果数据对每个处理器是唯一的,那数据可能就不需要使用锁来保护,因为数据只能被一个处理器访问。如果自旋锁没有被持有,内核又是抢占式的,那么一个新调度的任务就可能访问同一个变量。

  3. 即使是一个单处理器计算机,变量也会被多个进程以伪并发的方式访问。通常,这个变量会请求得到一个自旋锁(防止多处理器机器上的真并发)。但是如果这是每个处理器上独立的变量,可能就不需要锁。

  4. 可以通过preempt_disable()禁止内核抢占。这是一个可以嵌套调用的函数,当最后一次preempt_enable()被调用后,内核抢占才重新启用。例如:

preempt_disable();
/*抢占被禁止...*/
preempt_enable();
  1. 抢占计数存放着被持有锁的数量和preempt_disable()的调用次数,如果计数是0,那么内核可以进行抢占;如果为1或更大的值,那么内核就不会进行抢占。函数preempt_count()返回这个值。表10-11列出了内核抢占相关的函数。
    在这里插入图片描述
  2. 为了用更简洁的方法解决每个处理器上的数据访问问题,可以通过get_cpu()获得处理器编号(假定是用这种编号来对每个处理器的数据进行索引的)。这个函数在返回当前处理器号前首先会关闭内核抢占。
int cpu;
/* 禁止内核抢占,并将CPU设置为当前处理器 */
cpu = get_cpu();
/* 对每个处理器的数据进行操作... */
/* 再给予内核抢占性,“CPU”可改变故它不再有效 */
put_cup();

11. 顺序和屏障

  1. 当处理多处理器之间或硬件设备之间的同步问题时,有时需要在程序代码中以指定的顺序发出读内存(读入)和写内存(存储)指令。在和硬件交互时,时常需要确保一个给定的读操作发生在其他读或写操作之前。在多处理器上,可能需要按写数据的顺序读数据(通常确保后来以同样的顺序进行读取)。但是编译器和处理器为了提高效率,可能对读和写重新排序。所有可能重新排序和写的处理器提供了机器指令来确保顺序要求。也可以指示编译器不要对给定点周围的指令序列进行重新排序。这些确保顺序的指令称作屏障。

  2. a. rmb()方法提供了一个"读”内存屏障,它确保跨越rmb()的载入动作不会发生重排序。

    b. wmb()方法提供了一个“写”内存屏障,它确保跨越屏障的存储不发生重排序。

    c. mb()方法既提供了读屏障也提供了写屏障。

    d. read_barrier_depends()rmb()的变种,它提供了一个读屏障,但是仅仅针对后续读操作所依靠的那些载入。在有些体系结 构上,read_barrier_depends()rmb()执行得快,因为它仅仅是个空操作,实际并不需要。

  3. smp_rmb()smp_wmb()smp_mb()smp_read_barrier_depends()提供了一个有用的优化。在SMP内核中它们被定义成常用的内存屏障;在单处理机内核中,它们被定义成编译器的屏障。对于SMP系统,在有顺序限定要求时,可以使用SMP的变种。

  4. barrier()方法可以防止编译器跨屏障对载入或存储操作进行优化。内存屏障可以完成编译器屏障的功能,但是编译器屏障要比内存屏障轻量得多。实际上,编译器屏障几乎是空闲的。

  5. 表10-12给出了内核中所有体系结构提供的完整的内存和编译器屏障方法。
    在这里插入图片描述

注:本文摘自《Linux内核设计与实现(第三版)》
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值