从零开始自制一个OS

目录

本文讨论内容

第一节        自制RTOS快速体验

第二节        CM3架构解读

如何实现多线程/多任务

寄存器解读

MSP和PSP寄存器

第三节        任务调度的实现

OS基本数据类型创建

OS创建线程/任务

OS开始任务调度的实现(C语言)

OS心跳!PenSV和SysTick

OS任务切换的实现(汇编)


本文讨论内容

       本文将简述如何从零开始搭建一个OS,我们搭建OS类型为RTOS,硬件平台是ARM Cortex-M3!本文最终搭建出来的OS仅仅占用0.66KB空间!

       为什么要指定硬件平台进行搭建OS呢?因为OS要想高效的运行必须使用汇编对一些频繁的操作(例如任务切换)进行高效处理,而汇编跟处理器的架构有着很大的联系,所以本文就以ARM Cortex-M3这类处理器为例(这类处理器用得十分多)进行OS的搭建。

       那么我们搭建RTOS前有哪些准备工作呢?首先就是《Cortex‐M3 权威指南》,这本书中几乎有我们制作OS的所有依据,推荐大家阅读一遍再来看本文。其次就是要对RTOS这类OS有一定的了解,例如UCOS、FreeRTOS、Rtthread等等,本文也有一些思想是从这些RTOS中参考而来。

       最后,是不是我们要搭建整个OS内核?当然不是啦!尽管RTOS是迷你版OS,但是它的内核实现也是十分庞大的,我们就先搭建OS中最核心的东西多任务调度(或者叫多线程调度)!读者可以点赞+留言想要搭建出来的功能,我后续会继续搭建读者希望拥有的OS功能!下面开源我搭建出来得OS内核文件(可以在下面的链接下载压缩包,也可以用Git下载,教程可参考读者写的Git使用教程):

https://gitee.com/xiaowen_git/WenOS

第一节        自制RTOS快速体验

       读者下载了OS的文件源码后,里面会有一个“WenOS(多任务调度)_内核文件”文件夹(没错啦,OS就是用我名字命名的!!!),然后我们找来一份源码,一定要是CM3Cortex‐M3)内核的处理器!此处找来“正点原子-战舰(STM32F103ZE)开发板-寄存器版本-实验1 跑马灯实验”进行移植。我们先创建一个文件夹“Middlewares”,然后将WenOS直接复制过去,如下图:

       然后我们创建一个文件组,我这里命名为“Middlewares\WenOS”,将C文件和汇编文件添加进去,然后记得包含头文件!如下图:

       最后一步找到"stm32f10x_it.c”文件将PendSV和SysTick中断服务函数给屏蔽了(因为要在其他地方要写这两个中断函数),如下图所示:

       进过前面的步骤你已经完全搭建好WenOS环境了!!!那么接下来在main.c里面写个程序吧,笔者的程序如下所示,这个程序可以让LED0和LED1同时闪烁,其中LED0慢闪烁,LED1快闪烁。

#include "sys.h"

#include "led.h"



/* OS头文件 */

#include "Wenos.h"



/* 堆栈大小 */

#define LED0_task_stk_size  64

#define LED1_task_stk_size  64



unsigned int LED0_task_stk[LED0_task_stk_size];

unsigned int LED1_task_stk[LED1_task_stk_size];



/* 该函数使得LED0闪烁,延时1000ms,慢闪烁 */

void LED0_task(void *p_arg)

{

    while(1)

    {

        LED0=1;

        OS_delay_ticks(1000);

        LED0=0;

        OS_delay_ticks(1000);

    }

}



/* 该函数使得LED1闪烁,延时1000ms,快闪烁 */

void LED1_task(void *p_arg)

{

    while(1)

    {

        LED1=1;

        OS_delay_ticks(200);

        LED1=0;

        OS_delay_ticks(200);

    }

}



int main(void)

{

    /* 初始化LED,配置NVIC */

    LED_Init();

    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);

   

    /* 创建任务 */

    OS_create_task(LED0_task,&LED0_task_stk[LED0_task_stk_size-1],0,"123");

    OS_create_task(LED1_task,&LED1_task_stk[LED1_task_stk_size-1],1,"123");

   

    /* 开始任务调度 */

    OS_Start();

}

第二节        CM3架构解读

       考虑到可能有初学者正在进阶OS,那么本文就讲解一下CM3(Cortex-M3)架构的一些基本知识,如果读者对CM3架构已经十分熟悉可以跳过本节的内容。本节的参考资料是《Cortex‐M3 权威指南》。

  • 如何实现多线程/多任务

       读者可以回忆一下中断的处理过程,中断可以轻易打断当前任务的执行,从而去执行中断函数的内容,然后又继续执行原先的任务。那么最简单的多线程模式就是把不同任务函数放在不同定时器中断里面,然后开启定时器中断就可以实现了。但是这似乎不符合我们的原则,中断应该是快进快出的,而且如果有大量任务,如果都放进定时器中断执行,似乎定时器中断也不够用啊!

       那么要解决这个问题就要深入理解一下中断具体执行过程了!那么我们接下来看一下中断是怎么具体实现的,如下图是中断的实现过程,这个过程几乎适用于所有处理器的中断处理过程

       从中我们思考一下基于上图实现多线程!其实最重要的是就是保存现场和恢复现场这个过程了,这个属于底层的操作了,跟处理器架构会打交道。对于上层应用部分,我们可以构思一个框架,首先让一个中断持续发生以检测任务状态,如果检测到要切换任务就触发一个中断让它进行任务切换就行了(大多RTOS都是此框架)!至此,多线程的实现框架就有了,那么检测任务状态的中断要持续发生,应该用什么中断?这是一个问题,另一个问题,如何任务切换的中断是哪一个?这涉及到内核,当然要在《Cortex‐M3 权威指南》中寻找答案,如下图是《Cortex‐M3 权威指南》推荐我们使用的中断!

       所以CM3推荐我们使用SysTick中断和PenSV中断,那么我们的总体框架就是--SysTick中断检测是否需要任务切换,如果要切换就触发PenSV中断,PenSV中断内进行任务切换就行了!至此,CM3实现多线程框架已经完成!

  • 寄存器解读

       初学者可能会好奇,为什么这里要进行寄存器解读,因为前面没有具体说到中断是如何保存现场的,而且当我们在《Cortex‐M3 权威指南》找到中断具体实现过程后,如下图,就会发现中断也是在操作寄存器啊!

       这里会涉及到MSP和PSP寄存器,这个在本文后面再分析。我们看一下入栈的内容,是不是发现少了几个寄存器啊???这就是重点了,因为处理器帮我们入栈是几个重要寄存器,而有一些寄存器没有入栈。当然为了完整恢复现场,没入栈的几个寄存器我们要手动入栈和出栈!在《Cortex‐M3 权威指南》第26页会有完整的寄存器组表格,从中可查R4~R11 这几个寄存器没入栈,所以我们在写程序的时候,要手动对R4~R11这几个寄存器进行入栈和出栈!

  • MSP和PSP寄存器

       细心的读者会发现,在上面的寄存器描述中会出现MSP和PSP寄存器,这几个寄存器存在的意义其实《Cortex‐M3 权威指南》中有描述,但是初学者容易概念弄混,这里也解读一下这两个寄存器,如下两张图是“Cortex-M3 权威指南”中对这两个寄存器的描述。

 

       注意我圈起来的这个重点,CM3 的堆栈是向下生长的满栈,这个设计堆栈和保存寄存器都会用!总结来说,MSP和PSP是CM3架构专门给操作系统设计的,这样就可以分离用户和系统,在系统中使用MSP,而在用户中使用PSP,用户与操作系统互不影响(这波设计我只能说妙啊!)那么应该怎么用呢?在《Cortex‐M3 权威指南》的第42页有如下描述。

       这里也总结一下,也就是说异常\中断中使用的是MSP堆栈,而且芯片复位开始时就是使用MSP堆栈(此时无论用户进程还是系统进程都是MSP)。如果要用户进程要使用PSP堆栈,我们还要在中断中修改相关寄存器才行!

第三节        任务调度的实现

       这里就是实战环节了!会实际运用上面的理论知识,如果读者对架构还不清楚,就要多阅读几遍“Cortex-M3 权威指南”了~在实现任务调度前,我们看看总的设计思路是怎么样的,如下图是总体的设计思路!

  • OS基本数据类型创建

       别把这块想得太难了,通俗来讲,我们在这里要描述一下我们的任务长什么样子,代码如下所示:

typedef struct  OS_TCB

{

    /* 任务栈顶 */

    unsigned int *TCB_head;

   

    /* 任务的延时,默认为0,配合OS_delay_ticks()函数一起使用,实现任务休眠 */

    unsigned int sleep_time;

  

}TCB; /* 任务控制块 */

       这个就是我们的任务控制块了,已经学过RTOS的读者应该都不陌生,任务控制块就是代表了一个任务(或者叫做线程)的模样。但是我们有很多任务啊,还要集体的描述一下所有的任务吧?那么,上代码。

/* 任务就绪表,实际上就是一张优先级的表格,优先级为0~31 */

unsigned int  OS_readly_task;



unsigned char OS_present_prio;    /* 记录当前运行的任务优先级 */

unsigned char OS_high_prio;       /* 所有任务中的最高优先级数 */



TCB TCB_task_list[OS_task_max];  /* 任务控制块,记录了所有TCP列表 */

TCB *p_present_prio;               /* 指向当前任务的TCB */

TCB *p_high_prio;                  /* 指向最高级任务的TCB */

       这里为了简化起见,本文将OS设计成一个优先级下面只能拥有一个任务!想要设计成一个优先级下面能存在多个任务的读者,点赞+在看+留意想要搭建出来的功能,我后续会继续搭建读者希望拥有的OS功能!

       数组TCB_task_list记录着所有任务的TCB,TCB_task_list数组的下标号和OS_readly_task的bit位是一致的!除了这些,我们还要记录当前任务的优先级和TCB控制块,和保存最高优先级任务的优先级、TCB控制块,这样就可以进行任务切换了!

  • OS创建线程/任务

       创建任务,也就是开辟堆栈空间,这里注意R4~R11是不会自动保存的寄存器,这一点看第二节的寄存器解读讲解过,此处不累述。同时注意CM3架构是向下增长的,我们开辟空间后应该传入高地址作为栈底!如果用全局的数组变量传参,那么传入堆栈地址就是数组的&a[max-1]max代表数组大小。

void OS_create_task(void *task_fun,

                        unsigned int *task_stk,

                        unsigned char task_prio,

                        void *p_arg)

{

    /* 模拟将由上下文切换创建的堆栈帧中断 */

   

    /* xPSR状态寄存器,这个比较特殊,第24位是设置THUMB模式 ,栈底(高地址) */

    *(task_stk)=(unsigned int)0x01000000;

   

    *(--task_stk)=(unsigned int)task_fun;     /* 函数入口 */

    *(--task_stk)=(unsigned int)task_end ;    /* R14(LR) */

    task_stk -= 4;                            /* R12 R3 R2 R1 */

    *(--task_stk)=(unsigned int)p_arg;        /* R0(参数) */

   

    /* 未自动保存的内核寄存器:R4~R11 */

    task_stk -= 8;                            /* R4~R11 */

   

    /* 将该任务添加到控制TCB列表,即栈顶指向PCB控制块 */

    TCB_task_list[task_prio].TCB_head =task_stk;

    OS_readly_task|=0x01<<task_prio;         /* 优先级列表标记优先级 */

    TCB_task_list[task_prio].sleep_time =0;  /* 睡眠列表设置为0 */

}
  • OS开始任务调度的实现(C语言)

       接下来就实现任务的调度吧,这里是上层的任务调度,底层的应用调度需要操作寄存器实现,我们先完成上层调度底层的程序,这里要注意的是CM3架构的堆栈是向下增长,而数组的最高位&arr[max-1]是处在高地址的,因此MSP堆栈的栈低应该被赋值为& arr[max-1],而& arr[max-1]= CPU_msp_stk(基地址,也就是arr[0])+OS_mps_stk_size(数组大小,数组下标从0开始,所以要-1)-1;所以最终得到:

CPU_msp_stk_base=CPU_msp_stk+OS_mps_stk_size-1;这里还要创建一个空任务,防止CPU无事可做,代码如下:

#define        OS_mps_stk_size     128          /* 主堆栈大小 */

unsigned int  CPU_msp_stk[OS_mps_stk_size]; /* 主任务堆栈大小 */

unsigned int  * CPU_msp_stk_base;             /* 指向的是数组最后一个元素 */



#define       idle_stk_size       64           /* 空闲任务堆栈大小 */

unsigned int  idle_stk[idle_stk_size];      /* 空闲任务堆栈 */



void OS_Start(void)

{

    /* M3向下增长 */

    CPU_msp_stk_base=CPU_msp_stk+OS_mps_stk_size-1;

   

    /* 空闲任务 */

    OS_create_task(OS_idle_task,&idle_stk[idle_stk_size-1],OS_task_max-1,"123");

   

    /* 获得最高级的就绪任务 */

    OS_get_high_prio();      

   

    /* 当前运行任务的优先级列表好=最高优先级号 */

    OS_present_prio= OS_high_prio;

   

    /* 更新最高优先级TCP控制块 */

    p_high_prio=&TCB_task_list[OS_high_prio];

   

    /* 初始化滴答定时器 */

    System_init();

    OS_start_highprio();

}

       获得高优先级任务函数需要注意,RTOS需要具有高实时性,而获得最高任务函数会被频繁调用,所以读者不可用for()从就绪表查询,因为这种做法会导致OS实时性降低,也就是不能确定有确定的运行时间(用for()查询,如果优先级为0,执行次数=1,但优先级=20,则执行次数=21,所以我们引用一种类似类似二分法的算法,使得内核运行时间可以被确定,无论优先级是多少,执行步骤永远为5次。函数System_init()就是初始化滴答定时器了,第二节也讲到需要用SysTick中断为OS提供节拍。函数OS_start_highprio()将会使用汇编进行开始任务调度了,这个我们汇编的时候再讲解。综合,我们可以完成如下源码:

/* 每1000/System_Ticks ms进入一次中断 */

#define       System_Ticks        1000 



void System_init(void)

{

    unsigned int reload;

    /* 选择外部时钟  HCLK/8 */

    SysTick_CLKSourceConfig(SysTick_CLKSource_HCLK_Div8);

    /* 为系统时钟的1/8 */

    reload=SystemCoreClock/8000000;

    /* 根据System_Ticks设定溢出时间 */

    reload*=1000000/System_Ticks;

    /* 开启SYSTICK中断 */

    SysTick->CTRL|=SysTick_CTRL_TICKINT_Msk;

    /* 每1/System_Ticks秒中断一次,这里设置1ms */

    SysTick->LOAD=reload;

    /* 开启SYSTICK */

    SysTick->CTRL|=SysTick_CTRL_ENABLE_Msk;

}



/* 从优先级表格中查找最高优先级,然后保存到OS_high_prio中 */

__inline void OS_get_high_prio(void)

{

    unsigned int  x=OS_readly_task;

    OS_high_prio=0;

   

    /* 类似二分查找的算法 */

    if (0 == (x & 0X0000FFFF))

    {

        x >>= 16;

        OS_high_prio += 16;

    }

    if (0 == (x & 0X000000FF))

    {

       x >>= 8;

        OS_high_prio += 8;

    }

    if (0 == (x & 0X0000000F))

    {

        x >>= 4;

        OS_high_prio += 4;

     }

     if (0 == (x & 0X00000003))

    {

        x >>= 2;

        OS_high_prio += 2;

    }

    if (0 == (x & 0X00000001))

    {

        OS_high_prio += 1;

    }

}

       我们现在已经完成OS的大致框架了,所以接下来就是最重要的使操作系统运行起来,也就是让它拥有心跳!第二节讲到,OS的心跳需要两个配合,一个是让OS一直保持心跳(SysTick中断),另一个是遇到需要任务切换,让心脏(SysTick中断)告知大脑(PenSV中断)去任务切换!

       所以这部分也是很核心的东西,那么SysTick中断的具体内容是啥?那就是更新一下任务的休眠状态,如果有变化(也就是最高优先级任务不是现在任务)就触发一下PenSV中断(告诉大脑),这部分也不难,但是注意!SysTick中断和PenSV中断原本在"stm32f10x_it.c"这个文件被定义了,我们需要把这些定义给注释了!然后系统才能运行我们写的SysTick中断和PenSV中断处理函数。这部分在第一节快速体验中有这步操作,其实就是原先的中断处理函数加上注释符,这里不累述。那么综合上面所说我们就可以完成源码了!源码如下:

void SysTick_Handler(void)

{

    unsigned int cpu_sr;

    unsigned char i;

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

    {

        OS_ENTER_CRITICAL();

        if(TCB_task_list[i].sleep_time)

        {

            TCB_task_list[i].sleep_time--;

            if(TCB_task_list[i].sleep_time==0)

            {

                /* 从任务优先级表格中,恢复任务 */

                OS_readly_task|=0x01<<i;

            }

        }

        OS_EXIT_CRITICAL();

    }

   

    OS_task_schedule();       /* 进行任务调度 */

}



void OS_task_schedule(void)

{

    unsigned int cpu_sr;

    OS_ENTER_CRITICAL();      /* 进入临界区 */

    OS_get_high_prio();       /* 找出任务就绪表中优先级最高的任务 */

    /* 如果不是当前运行任务,进行任务调度 */

    if(OS_high_prio!=OS_present_prio)

    {

        p_high_prio=&TCB_task_list[OS_high_prio]; 

        OS_present_prio= OS_high_prio;/* 更新最高优先级任务 */

        OS_task_switch();    /* 进行任务调度,也就是触发PenSV中断 */

    }

    OS_EXIT_CRITICAL();      /* 退出临界区 */

}
  • OS任务切换的实现(汇编)

       这部分的内容是最最核心的地方,有读者会问:难道任务切换就不能用C语言写吗?我的回答是:可以,但没有必要。因为在多任务处理时,任务要经常被切换的!所以一定要高效地执行!那么最高效的语言就是机器语言(也就是用0,1)编程,这个难度巨大,我们就用简单一点的--汇编语言吧!那么如果没学过汇编的读者可以先看一下《Cortex‐M3 权威指南》的第四章“指令集”,学习一下汇编的使用!

       我们先看看简单的汇编部分吧,看看关中断、开中断和任务切换怎么写,源码如下:

NVIC_INT_CTRL       EQU     0xE000ED04; 中断控制寄存器

NVIC_PENDSVSET      EQU     0x10000000; PendSV触发值

OS_CPU_SR_Save             ;PRIMASK=1,关中断(NMI和硬件FAULT可以响应)

    MRS     R0, PRIMASK

    CPSID   I

    BX      LR



OS_CPU_SR_Restore          ;恢复中断,RO保存着当前中断状态

    MSR     PRIMASK, R0

    BX      LR

   

OS_task_switch        ;触发PendSV

    LDR    R0,=NVIC_INT_CTRL

    LDR    R1,=NVIC_PENDSVSET

    STR    R1,[R0]

    BX     LR   

       这里可以看出来。汇编其实也不难!任务切换的实质就是触发PenSV中断,如何触发呢?往中断控制寄存器对应位写1就行了!接下来看看函数OS_start_highprio(),那么我们看看这是何方神通吧,源码如下:

OS_start_highprio

    CPSID    I         ;关中断

    MOV32    R0, NVIC_SYSPRI14

    MOV32    R1, NVIC_PENDSV_PRI

    STRB     R1,[R0]   ;设置PendSV的异常中断优先级为最低等级



    MOVS    R0,#0

    MSR     PSP,R0     ;PSP清零,作为首次上下文切换的标志



    LDR     R0,=CPU_msp_stk_base

    LDR     R1,[R0]

    MSR     MSP,R1     ;将MSP堆栈设为CPU_msp_stk_base,区分PSP堆栈



    LDR     R0,=NVIC_INT_CTRL

    LDR     R1,=NVIC_PENDSVSET

    STR     R1,[R0]    ;触发PendSV异常



    CPSIE   I          ;开中断

       原来就是设置一下PenSV中断等级、设置PSP和MSP堆栈,那么这两个堆栈有什么区别呢?这个在第二节的第③点讲述到,简单来说,在OS中,PSP常用于用户级模式,MSP常用于OS特权模式!那么为什么要讲PSP清零啊?当然是为了做个标志啊!至于具体作用,给读者留下一个悬念,后续我们会讲解这个清零的重要性!

       那么接下来就是看看PenSV中断了,前面说过这部分是OS的大脑,尤为重要!PenSV中断里面就是具体的如何任务切换了,我们分析一下源码,源码如下:

PendSV_Handler

    CPSID   I           ;关中断

    MRS     R0,PSP      ;把PSP指针的值赋给R0

CBZ     R0,OS_CPU_PendSV_Handler_first

;如果PSP=0,表示第一次执行中断,会跳到OS_CPU_PendSV_Handler_first

   

    SUBS    R0,R0,#0x20 ;使用STM指令手动入栈,SUBS不会改变栈指针的位置,手动改变

    STM     R0,{R4-R11}

   

    LDR     R1,=p_present_prio

    LDR     R1,[R1]     ;R1=p_present_prio,也就是R1=p_present_prio->StkPtr

    STR     R0,[R1]     ;PSP=p_present_prio->StkPtr

   

OS_CPU_PendSV_Handler_first

    LDR    R0,=p_present_prio

    LDR    R1,=p_high_prio

    LDR    R2,[R1]

    STR    R2,[R0]       ;p_present_prio=p_high_prio

   

    LDR    R0,[R2]       ;将新的栈顶给R0,实现PSP=p_TCBHightRdy->StkPtr

   

    LDM    R0,{R4-R11}   ;推出R4-R11

    ADDS    R0,R0,#0x20  ;LDM指令入栈不会改变栈指针的位置,手动改变

   

    MSR    PSP,R0

    ORR    LR,LR,#0x04   ;置LR的位2为1,则用户线程使用PSP,否则用户线程使用MSP

   

    CPSIE    I           ;开中断

    BX       LR



    END

       如果是初次学习汇编的读者需要注意PendSV_Handle和OS_CPU_PendSV_Handler_first是标签来的(类似C语言中goto语句中标签的含义),如果在PendSV_Handle中没有手动退出,程序就会一直执行到OS_CPU_PendSV_Handler_first处。

       接下来分析源码,语句CBZ  R0,OS_CPU_PendSV_Handler_first就是判断R0是否为0,如果为0就跳转到OS_CPU_PendSV_Handler_first标签处,而R0在前面的赋值就是PSP的值,这里再细想一下前面设置PSP=0的原因了,原因就是PSP=0时表示第一次执行任务调度,此时不需要入栈,只需要将寄存器出栈就可以了!当任务运行后PSP会指向具体的任务堆栈,也就是PSP=0,此后每次任务切换都要保存当前任务的堆栈,然后再弹出最高优先级任务的寄存器了!在退出PendSV后我们一定要手动将用户堆栈设置为PSP堆栈,不然用户堆栈也是MSP堆栈!

       本文以第一次任务切换后,后续的任务切换过程为例分析其实现过程,首先就是当前的任务入栈了,要用“STM”这个指令手动入栈,所以我们先要自减0x20地址,也就是十进制32,并保存R4~R11共8个寄存器(因为每个寄存器占32bit,即4字节,占4个地址,也就是要自减少”4地址/寄存器 * 6个寄存器=32个地址”)。为什么要减少而不是增加,因为CM3的堆栈向下增长的!我们出栈则相反,要自增32个地址再出栈。对于初学的读者可能不太好理解,那么我就以寄存器出栈为例,画一幅出栈的过程图给读者吧,如下图所示:

       至此,自制RTOS的所有原理已经讲解完毕,这个OS内核目前只实现了多任务调度,读者可以点赞+留言想要搭建出来的功能,我后续会继续搭建读者希望拥有的OS功能!我们下期见!!!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

芯心智库

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值