Linux下 _schedule()函数详解

前言 

俗话说,好记性不如烂笔头, 虽然之前对schedule函数有一些宏观的认识,但是不够清晰,然后就去阅读linux源代码,发现之前对一些方面理解是有错误的,现将其记录下来,lInux下开发者的水平还是很高的,考虑的事情足够详细,因为schedule函数必须考虑在各种情况下的调用,在各个平台下的兼容,以及linux版本迭代,本文基于 Linux 4.19版本,硬件架构是arm64,阅读本文需要一些arm64汇编基础,本文有一些地方可能由于本人才疏学浅,敬请各位大牛赐教,本人洗耳恭听。

一 .谁调用了_schedule函数 

我们知道调度一般可以分为抢占调度和主动调度,因此_schedule是函数是在这两种背景下被调用,所以分析_schedule函数比分析通用调度器更具有通用性

在主动调度中,我们一般会设置进程暂时休眠,所以在主动调度中会把进程从运行队列中暂时删除,等到进程被唤醒时再次加入运行队列中,而抢占调度一般是在进程还处于运行态的情况下调用,这个时候进程只是被迫让出cpu,但是进程还在运行队列中,直到下次调度器选择它,所以分析_schedule函数比分析schedlue更具有通用性,如下图主调度器中调用_schedule函数

可以看到主调度器中是调用_schedule(false)完成其主要调度工作,参数false代表这是主动调度,而参数true代表在抢占调度中调用,如下图是抢占调度中调用_schedule。

二._schedule函数总体算法流程

遵循一贯原则,首先讲清楚_schedule函数做了什么,干了什么,总体的算法流程是什么,这样可以对_schedule有一个总体的认识,然后才是深入各种细节,把握其设计精华部分。我们知道能调用_schedule函数一定是系统确实需要选择新的进程进行运行,我们把当前正在运行的进程称为prev进程,待选择运行的进程称为next进程,所以在_schedul主要是做以下几部分内容,如下

1)跟据prev进程状态和抢占标记对prev进程做清理工作,主要是判断要不要把prev进程移出当前运行队列,这部分代码是没有调用函数,而是直接由_schedule给出。

2)去运行队列选择下一个待运行的进程,这是由调度类和调度策略决定的,主要由函数pick_next_task完成选择工作。

3) 根据选择的next进程做切换处理,主要通过调用context_switch函数完成,这是进程切换的核心部分。

4)触发负载均衡调度,由函数   balance_callback(rq)完成。                                                          因为负载均衡调度不是本文的重点,所以本文不对其进行讲解, 所以我们主要围绕以上前三点进行详细讨论。    

三. 调度前的清理工作

        这是调度前的必要工作,如果不做,可能会影响调度函数的第二部分,即它可能影响选择队列中选择下一个进程的判断,我们知道进程可能是主动调度从而引发切换,也有可能是抢占调度引发切换,主动调度可能是暂时的,因为这可能是进程暂时没有得到某种资源而进行睡眠,还有可能是进程死亡了要进行调度,这两种状态都要把进程从运行队列中删除,区别是睡眠状态进程可能某个时间段可以被唤醒,但是死亡的进程无法唤醒,因为它的task_struct已经被系统回收了,无法被唤醒,只能等待再次被创建。

对于抢占调度我们就轻松多了,抢占说明之前进程还活着,进程只是优先级不够高了,所以暂时把进程从cpu撤下去,换一个运行队列中具有最高优先级的进程进来,这就是抢占的本质。对于抢占我们不需要做太多清理工作,因为其运行状态没有改变,我们只是会把它从新放入运行队列进行排队,这个运行队列其实是个统称,因为linux有五种调度类,但是却有三种运行队列,下面是运行队列的定义

可以看到有三个不同的运行队列,分别对应完全公平调度队列,实时队列,最后期限调度队列,我们在这做的工作就是根据prev进程状态和抢占标志把进程从运行队列删除或者不删除,具体原则如下

(1) 如果当前preempt=true 说明此时是内核抢占调度,此时不需要把进程从运行队列删除。

(2) 如果preement=false,prev->state=runing,此时是执行用户抢占,这种情况是有更高级进程被设置,或者是实时进程时间片用完,完全公平调度类进程时间片用完等,这种情况下也不需要把进程移出运行队列。

(3)preement=false,prev->state>0,此时进程不运行,可能是睡眠或者已经死亡,无论死亡还是睡眠都要把进程从运行队列删除。

此时我们只需要处理第三种情况,下图为完整代码

此时因为我们已经得到进程不允许运行,但是还是有一些情况要判断,如果此时进程的信号悬起位被设置,说明此时还有未出来信号,所以强制把进程的状态设置成运行,此时进程还会再次等待运行,因为它要去处理信号。如下图代码判断

如果此时没有待处理信号就调用deactivate_task()函数把prev进程从运行队列删除,deactivate_task函数会去回掉当前调度类的dequeue_task函数,然后去执行出队函数,deactivate_task函数如下图

可以看到他会把运行队列中睡眠状态的进程数量加1,然后执行dequeue_task函数,此函数如下图

到此前期的准备工作已经完成。

四.pick_text_task函数详解

4.1调度的总体原则

      pick_text_task 函数可以说是本文重点之一,还是一贯案例,我们先说这个函数的总体算法,这个函数称为选核函数,就是遍历各个调度类,然后从中选择优先级最高的进程作为下一个待运行进程,这个算法的调度类优先级是stop_sched_class->dead_sched_class->rt_sched_class->fair_sched_class->idle_sched_class的顺序遍历各自的运行队列,一旦对应的运行队列不为空,那么就返回其中调度类上最高优先级进程,所以我们可以认为选择进程的总体原则是,先遍历调度类,如果调度类中可以运行进程数量不为0,然后就选择调度类中的最高优先级进程,所以我们可以认为调度的总体原则是先选择调度类,然后再选择调度类中进程,下图为pick_text_task函数代码

可以看到函数进行了优化,先从fair_sched_class进行判断,这是有原因的,因为Linux进程大部分属于是完全公平调度类的进程,所以先从fair_sched_class判断,可以加快选择的速度,这也是一种分支选择的手段吧,因为我们知道进程属于fair_schedule_class类的概率最大,运行队列有一个nr_runing计数,代表全局运行队列中可运行进程数,而cfs.h_nr_runing代表fair_schedule_class类可运行进程数,此时如果两者相等,则说明当前只有fair_schedule_class类中有可以运行进程,所以只能到这个类中去选取可运行进程,下图为判断代码

可以看到如果当前只有fair_schedule_class类进程,则去执行fair_sched_class对应的pick_next_task函数,否则进行遍历,去寻找最高优先级的进程,代码如下图所示,

可以看到直到找到第一个非空进程,函数才会返回,此时如果对应的调度类里面没有可运行进程,那么执行class->pick_next_task函数后就会返回空指针,然后继续去下一个类中寻找,for_each_class是一个宏,展开如下图

因此对应各个类的pick_next_task函数就显得很重要,这个函数完成实际调度类选择进程的工作,所以我们详细解析这个函数。

4.2 pick_next_task_stop函数解析

stop_sched_class调度类作为调度类中最高优先级的,如下图赋值

所以我们可以肯定,如果一个进程被设置成stop_sched_class调度类,那么它可以抢占任何一个进程,而且同一个运行队列不应该有两个stop_sched_class类进程,因为运行队列只有一个指向stop_sched_class进程的指针,所以以下是对stop_sched_class进程的选择进程原则

1)stop进程同一时间在同一运行队列只有一个。

2)stop进程只能抢占别的进程,它不能被别的进程抢占,自能自愿放弃cpu。

3)目前的kernel中只有migration task来使用,且不会给Userspace使用。

这些原则指导着stop_sched_class选择进程,所以我们可以猜测,如果当前运行队列有stop_sched_class进程,那么直接返回运行队列的stop指针就行,因为如果其不为空,那么只能是它运行,stop_sched_class类的选择函数为pick_next_task_stop,具体代码如下图所示

可以看到,上面的总结原则很好的在代码中体现,其还调用put_prev_task函数释放prev 进程的调度实体,其调用各个类的put_prev_task函数,如下图代码所示

对于此函数我们不过多解析,只需要知道这个函数就是把对应的调度实体从对应的运行队列释放就可以了。

4.3 pick_next_task_dl 函数解析

dl_sched_class调度类作为仅次于stop_sched_class类的调度类,所以如果stop_sched_class中没有就绪进程那么会到dl_sched_class中寻找可运行进程,dl_sched_class调度类维护了一个运行队列,所有属于该调度类的进程被会被这个运行队列组织成一颗红黑书并放入其中,所以运行队列是个泛称,有时候这个队列也可能是一颗树,下图是deadline的进程树

dl调度器作为linux实时调度器的其中一个,另外一个是固定优先级调度,dl调度器实现了最早期限调度策略和cbs调度策略,所以从dl运行队列选择进程的规则可以总结如下

1)选择最小到期的进程,此时用dl_deadline表示,这个值是表达进程最后应该被执行完的时间。

1)确保dl任务宽不会超过1(单核上),否则dl进程将不能良好工作。

1)在每次时钟中断中减少dl进程的看运行时间,当dl可运行时间为0时,说明此时dl进程已经运行完毕,必须强制使其放弃处理器,等待下一个周期再次运行。

另外dl进程是有大概率会被迁移到其他cpu上运行,因为如果当前cpu上的dl进程的带宽使用率为1,则必须迁移到其他cpu上运行,为此在选择dl进程时第一件事就是各个cpu的负载均衡,如下图所示

拉取任务后可能会影响当前dl的运行队列,此时进程树会增加高度或者叶子,而且最左节点也可能改变,所以它会影响下一步的选取最左节点处理,下图是选择最左节点代码

核心在于pick_next_dl_entity函数的处理,其就是选择最左节点,因为最左节点代表最先到期了,所以它必须要最先执行,代码如下所示。

4.4 pick_next_task_rt 函数解析

当stop_sched_class和rt_sched_class调度类没有可运行进程那么就会调用pick_next_task_rt函数从rt队列中寻找可运行任务,rt维护一个优先级数组,每个数组元素代表一个优先级,优先级范围是0-99,这个数组的类型是双向链表头,如下图为其优先级队列定义

可以看到不仅定义了一个优先级数组还定义一个位映射表,位映射表用于加快查找数组中第一个不为空的元素,在创建一个rt进程或者改变一个进程调度类,让其成为rt进程时,就会把此进程放入queue对应的链表中,我们知道实时进程必须在普通进程之前优先运行,所以为保证实时进程的优先性,我们有可能会把当前运行队列中实时进程迁移到其他运行队列中去,只有这样才能保证实时进程具有全局的优先性,所以在rt运行队列中定义一个可pushable_tasks节点,当把进程加入queue队列时也把他放入pushable_tasks节点,此时如果运行队列实时进程较多,可能会把pushable_tasks节点进程放入其它的运行队列中,下图为pshable_tasks节点定义

所以pick_next_task_rt选取任务算法如下

1)判断当前运行队列要不要拉取其它运行队列的实时任务,这一步可能影响当前的queue队列。

2)查找位映射表,寻找当前rt运行队列最高优先级队列任务,如果为空返回null。

3)在多运行队列中做pushable_tasks队列的负载均衡处理。

函数need_pull_rt_task用来判断要不要从别的运行队列拉取实时任务,如果需要则拉取到本队列中,如下代码所示

然后查找位映射表,代码如下

最后是负载均衡,代码如下

4.5 pick_next_task_fair 函数解析

因为普通进程在运行队列中最有可能出现,所以选择普通进程是最常见的,普通进程使用 一种叫做完全公平调度思想对待所有的运行任务,这个调度思想是按比重获得cpu时间片,所以各个人物获得的实际时间片是完全不一样的,然后定义一个虚拟时间的概念,当一个调度周期完成之后这个虚拟时间一定会相同,这就是完全公平调度的由来。他的具体技术就是实际运行时间大的进程的虚拟时间增长慢,而实际运行时间小的进程虚拟运行时间增长快,所以通过设置不同的增长速度就可以达到最好虚拟时间相同目的,而这里还有体现一个公平的特点就是:实际运行时间大的进程不会一直运行,因为它的虚拟运行时间也在增加,时钟中断到来时会判断此时运行队列中具有虚拟最小运行时间的进程,从而引发用户抢占调度,那么就可以达到实际运行时间小的进程也不会最后获得cpu运行时间的现象,从而引发执行顺序上的公平。此时的运行队列我们称之为cfq运行队列,它和dl进程一样也是用红黑树进行组织,其定义如下图

它的选择算法会稍微复杂一些,因为它要考虑prev任务和fair_sched_class的关系,主要是判断prev进程是不是属于fair_sched_class调度类,因为这涉及到把prev进程入队和出对列的判断,这主要是基于cfq运行队列维护的红黑树是这样的,cfq的红黑树中当前正在运行的进程不在红黑树中,所以更新当前进程的时间时不会移动当前进程在红黑树中的位置,这和dl的红黑树是不同的,但是尽管curr当前不在红黑树中,但是cfq的nr_ruing依然把curr计算在内,所以cfq的运行队列应该是这样的:包含curr和由rb_root组成的树,此时如果curr运行时间完成或者curr的虚拟运行时间不是最小,那么就会把curr入队列,然后把红黑树的mostleft出队列,把curr设置成mostleft,所以pick_next_task_fair的完整算法如下

1)判断prev进程是否属于fair_sched_class调度类,如果属于走慢速路径,否则快速路径。

2)慢速路径:根据prev进程状态,选择是否把prev进程入队列,(为什么要看prev状态,因为此时如果prev已经退出或者休眠那么在decative函数中会把prev出队列,因为prev不会在运行,否则prev进程只是当前被其他的fair_sched_class进程抢占。)如果prev还是处于运行态,那么会把prev进程入队列,然后从红黑树中选取最左进程作为下一进程。

3)快速路径,此时prev进程不是fair_sched_class进程,所以直接在红黑树中选取最左进程作为下一进程。

1)的判断代码如下

2)的代码如下

3)的代码如下

4.6 pick_next_task_idle

选择idle任务只有在经过fair_sched_class中没有一个任务和经过负载均衡后队列中依然没有任务,那么此时会执行idle任务,idler任务是每个运行队列有且只有一个,在系统初始化时候就初始化好了,rq->idle专门用于指向idle任务,如下图为rq->idle指针

所以我们不难猜测pick_next_task_idle函数算法其实很简单,就是返回rq->idle指针指向的空闲任务,其代码如下图所示

五. context_switch函数完全解析

经过pick_next_task函数我们已经选取下一个即将运行的任务,此时就要进行最为底层的操作,为什么说是底层,因为context_switch函数涉及到很多架构相关和汇编相关,在不同架构下,这个函数差别还是很大,所以我们这里只是解析基于arm64架构的相关汇编,要读懂它需要先了解arm体系结构和汇编代码。

context_switch主要是做切换的,从用户态来说就是从一个进程无缝切换到另外一个进程,但是这只是表面现象,实际上所有的切换都发生在内核空间,我们说context_switch切换主要包括两个方面,第一是地址空间的切换,第二就是内核栈的切换,那为什么有些书上说是处理器状态切换呢?这其实是不太严谨的,因为只有在切换内核栈之后才会有处理器状态切换,因为处理器状态就包含在内核态堆栈上面,而内核栈在task_struct结构体的stack字段上面,所以不难猜测context_switch一定是围绕地址空间和内核栈切换进行编写,下图为其完整代码

5.1 地址空间切换详解

       地址空间这个词在linux下用的很频繁,为什么出现地址空间,就是因为多道程序的出现,在这个背景下mmu被设计出来,正是因为有mmu才有进程的地址空间这个说法,因为在没有mmu下程序只能访问有限地址,即使采用分段单元也不能随意访问任意地址空间,这是因为物理空间只能被一个进程标识,如果随意访问就会出现越界问题,但是mmu出现后,改变了这个现状,程序可以随意访问其地址空间,这就是说进程A可以访问0xffff0000这个地址单元,进程B也同样可以访问这个地址单元,这是如何做到的?,与采用分段设计不同,分段设计是把物理地址划分为:段基址+段内偏移,此时段内偏移可以相同,但是段基址一定是不相同的,此时访问依旧有很大局限性,所以分页出现后,我们就可以访问4G地址空间(以32位程序为列)此时进程不再直接访问物理地址空间,而是访问虚拟空间,所以就是说虚拟地址空间是相同的,但是其对应的物理地址空间是不同的,而从每一个进程角度看它都是独自享有这4G的地址空间。

这个虚拟地址空间和物理空间的对应关系,或者叫做映射表,我们把这个表存储在物理内存中,然后把这个表的物理基地址给mmu,此时mmu就能根据虚拟地址去寻找正确的物理地址,所以虚拟地址空间切换就是映射表的切换,一般映射表采用三级映射,如下图

当然这是基于x86架构的,所以其页表基地址寄存器是cr3,在arm64下,有两个页表基地址寄存器,分别是ttbr0_el1和ttbr1_el1,其中ttbr0_el1是用户空间的页表基地址寄存器,而ttbr1_el1是内核空间页表基地址寄存器,我们需要设置的是用户空间页表基地址寄存器,所以在这里就可以理解为什么切换地址空间,因为只有切换了地址空间才能通过mmu找到属于自己的物理地址,所以我们可以这样说,切换地址空间就是切换用户页表,所以我们接下来讨论怎么切换页表,其实切换页表从原来上很简单,无非就是给页表基地址寄存器ttbr0_el1设置一个值吗?,的确,事实上确实是这么做的,但是情况却不只是那么简单,让我们考虑prev和next的情况就知道了,我们知道内核进程没有用户空间,所以如果next进程是内核进程就不用切换地址空间了,因为它压根就没有,再者就是如果next进程和prev进程是属于同一个线程组,那么也不用切换用户地址空间,因为同一个线程组的线程共享一个地址空间,所以这两种情况不会切换用户地址空间,所以切换地址空间算法就是总结如下

1)如果next进程是内核进程,那么不需要切换用户地址空间,此时执行懒惰tlb处理。

2)如果next进程和prev进程同属于一个线程组,那么不需要执行用户地址空间处理。

3)如果上述两种情况都不成立,那么进行地址空间切换,调用函数switch_mm完成地址空间切换

首先我们讨论情况1:此时next进程为内核线程,因为内核线程只会运行于内核空间,没有用户空间,所以内核线程的task_struct->mm字段是null,而有active_mm是用于访问内核空间的,我们知道,在x86架构下只有一个进程页表基地址寄存器,那就是cr3,此时用户态和内核态页表是放在一起的,如在32位程序下,pgd全局页表的前768项代表用户页表,此时覆盖3GB虚拟地址空间,而后256项用于内核空间,此时内核虚拟空间占用1GB,所以如果是切换到内核线程那么它不得不借用前一个进程的页表,所以此时内核线程的active_mm被赋值为prev进程的页表。但在arm64架构下此时内核线程地址翻译不需要借助用户页表,因为arm64有专门用于内核空间的页表基地址寄存器ttbr0_el1,其在内核初始化时被赋值为swapper_pg_dir,所以在arm64的内核线程其active_mm不会用到,但是为了兼容不同体系架构,我们还是把其active_mm赋值为前一个进程的active_mm,下图为其代码

可以看懂内核线程借用前一个进程的地址空间,enter_lazy_tlb用于执行懒惰tlb,因为没有切换用户地址空间我们不需要刷新tlb,所以懒惰tlb就是不刷新tlb.

然后是情况2和3:此时都是调用函数switch_mm处理,在switch_mm中判断其对应的地址空间是不是相同,如果相同则不用切换(这是情况2)否则进行切换(情况3),下图为完整代码

其实判断代码prev==next包含多种情况,第一种就是如果prev和next是同一个线程组的成员,那么prev=next,还有就是如果prev是内核线程,那么其active_mm是借用一个用户进程的,如果恰好借用的是next进程的mm,那么也不用切换,因为内核线程压根不会使用用户空间。否则就要调用_switch_mm函数切换用户空间,下图为其源代码

其中next==&init_mm应该从来不会成立,因为没有一个用户进程会引用它,即使是内核线程那也是active_mm引用它而不会是mm引用它。下面主要分析check_and_switch_context函数,这个函数是完成切换的核心函数,在这里还要说明asid,这是理解该函数的核心,asid即address-space identifier即地址空间标明码,为什么需要asid,因为在tlb中如果切换一次进程就刷新一次tlb,那么就会使得tlb刷新的太频繁,此时产生的坏处就是tlb缓存缺失很严重,这就迫使mmu去内存中访问页表以填充tlb,这就造成极大的延迟。所以为了避免tlb被频繁刷新,采用asid来设计,此时达到刷新tlb的条件才会进行刷新,否则只是会进行设置ttbr0_el1而不刷新tlb。check_and_switch_context函数用来实际切换地址空间,其算法如下:

1)判断next进程的asid是不是与prev进程asid处于同一代,如果是同一代则不需要申请新的asid,此时走快速路径 ,否则需要申请新的asid,此时走快速路径。

2)慢速路径:此时先申请一个新的asid,然后执行刷新tlb指令,最后去执行快速路径。

3)快速路径:此时只需要执行地址空间切换,调用函数cpu_switch_mm完成。

完整代码如下

其中函数cpu_switch_mm完成实际切换,其代码如下图

其中那个BUG_ON设计的很好,因为ttbr0_el1只能是设置为用户空间的页表基地址,而swapper_pg_dir是内核空间的页表,所以此时肯定不能执行,一旦执行就引发内核崩溃。cpu_do_switch_mm是个汇编函数,在文件arch/arm64/mm/proc.S中它主要是设置ttbr0_el1和ttbr1_el1,此代码如下

到此进程地址空间切换完毕。

5.2 进程内核栈切换

这部分主要是由函数switch_to完成,我们知道进程进入内核态是会有断点,这些断点信息保存在内核栈中,一个用户进程进入内核态无非就是从中断或者系统调用中进入,进入时把用户态断点信息保存在内核栈中,内核栈顶部用于保存结构pt_regs中,此结构如下

此时可以看到有cpu寄存器和cpu状态,所以这是能恢复一个用户进程的关键,然而我们并不是在_schedule函数中把pt_regs内容直接弹出到cpu当中,因为_schedule只是一个内核函数,不是中断返回,中断返回才是真正的把pt_regs弹出到cpu寄存器,所以这其实有两部,到switch_to为止,我们都处于prev进程的内核栈当中,所以如果我们要返回next进程用户空间,必须先切换到next进程内核栈当中去,然后中断返回执行eret指令把next进程的断点信息恢复到cpu当中去,这个时候就完成用户切换,此时next就开始运行,switch_to是一个宏定义如下

其调用_switch_to完成切换,其代码如下

可以看到其真正的切换函数是cpu_switch_to,这是一个汇编函数,其完成内核栈切换,我们在这里要说明,进程都是从cpu_switch_to切换出去的,同时进程也是从cpu_switch_to下一个函数切换回来,这就是说prev进程到cpu_switch_to就结束了,此后的内核态就是代表next进程在运行,可能理解起来又有点难,但是如果你真正理解切换就明白,从这里我们可以得到prev进程在调用cpu_switch_to时要保存prev进程内核断点,要保存的有pc,sp等寄存器信息,因为这个断点是内核函数调用引起,所以不用像进入中断那样保存全部寄存器消息,此时这个函数断点保存在哪?肯定不能是在内核栈当中了,因为这会造成进入内核栈时要从内核栈中取出内核栈指针的问题,这个就像是先有鸡还是先有蛋问题,所以我们在task_struct结构体成员thread里面定义了一个cpu_context专门保存内核态断点,thread成员如下所示

cpu_context成员如下所示

所以cpu_switch_to的主要工作就是保存prev进程内核态断点信息到prev进程的cpu_context当中去,然后把next进程cpu_context内容恢复到cpu中,此时就完成了内核态切换,然后我们可以说内核是开始代表next进程运行,这个汇编函数详解如下,代码中有详细注释

这里其实有个小技巧,就是在调用cpu_switch_to函数时,对于prev进程来说会把lr设置成cpu_switch_to函数下一条指令,所以prev就此停止,所以你可以猜想那么next的cpu_context里面的lr是什么?没有错就是cpu_switch_to函数得下一个指令处,这就保证了从哪里切换出去,又从哪里切换回来的原则,同时也保证_schedule函数的连续性,到此,切换内核栈完毕,只等发出eret指令返回next进程的用户空间即可。

  • 12
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值