FreeRTOS_调度器开启和任务相关函数详解

目录

前言 

1. 调度器开启过程分析

1.1 指令集

1.2 任务调度器开启函数分析

1.2.1 内核相关硬件初始化函数分析

1.2.2 使能 FPU 函数分析

1.2.3 启动第一个任务

1.2.4 SVC 中断服务函数

1.2.5 空闲任务

2. 任务创建过程分析

2.1 任务创建函数分析

2.2 任务初始化函数分析

2.3 任务堆栈初始化函数分析

2.4 添加任务到就绪列表

3. 任务删除过程分析

4. 任务挂起过程分析

5. 任务恢复过程分析


前言 

        在之前的学习过程中,已经学习了 FreeRTOS 的任务创建和删除,挂起和恢复等基本操作,并且也学习了分析 FreeRTOS 源码所必须掌握的知识:列表和列表项。但是深究其过程,任务究竟是如何被创建、删除、挂起和恢复的?以及系统是如何被启动的?这些我们还并不了解,一个操作系统最核心的内容就是多任务管理,所以我们非常有必要去学习一下 FreeRTOS 的任务创建、删除、挂起、恢复和系统启动等。

1. 调度器开启过程分析

        在学习调度器开启过程之前,我们先来学习以下 ARM 的相关汇编指令。

1.1 指令集

汇编语言:基本语法

        汇编指令的最典型书写模式如下所示:

标号

        操作码        操作数1,操作数2,……        ;注释。

        其中,标号是可选的,如果有,它必须顶格写。标号的作用是让汇编器来计算程序转移的地址。

        操作码是指令的助记符,它的前面必须有至少一个空白符,通常使用一个 “Tab” 键来产生。操作码后面往往跟随若干个操作数,而第 1 个操作数,通常都给出本指令执行结果的存储地。不同指令需要不同数目的操作数,并且对操作数的语法要求也可以不同。举例来说,操作数必须以 “#” 开头,如

        MOV R0,        #0x12                ;R0 <—— 0x12

        MOV R1,        # 'A'                   ;R1 <—— 字母 A 的 ASCII 码

        注释均以 ";" 开头,它的有无不影响汇编操作,只是给程序员看的,能让程序更易理解。

        还可以使用 EQU 指示字来定义常数,然后在代码中使用它们,例如:

NVIC_IRQ_SETEN0        EQU        0xE000E100

NVIC_IRQ0_ENABLE        EQU        0x1

        ……

        LDR        R0,=NVIC_IRQ_SETEN0        ;在这里的 LDR 是个伪指令,它会被汇编器转换成

                                                                           ;一条 "相对 PC 的加载指令"

        MOV R1,        #NVIC_IRQ0_ENABLE        ;把操作数传送到指令中

        STR        R1,[R0]                                         ;*R0=R1,执行完此指令后 IRQ #0被使能

        注意:常数定义必须定格写

        如果汇编器不能识别某些特殊指令的助记符,此时就要 "手工汇编" ——查出该指令的确切二进制机器码,然后使用 DCI 编译器指示字。例如,BKPT 指令的机器码是 0xBE00,即可以按如下格式书写:

        DCI        0xBE00                ;断点(BKPT),这是一个 16 位指令

        (DCI 也必须空格写——译注)

        类似地,还可以使用 DCB 来定义一串字节常数——允许以字符串的形式表达,还可以使用 DCD 来定义一串 32 位整数。它们最常被用来在代码中书写表格。例如:

        LDR        R3,        =MY_MUMBER        ;R3=MY_MUMBER

        LDR        R4,        [R3]                           ;R4=*R3

        ……

        LDR        R0,        =HELLO_TEXT        ;R0=HELLO_TEXT

        BL           PrintText                                   ;呼叫 printText 以显示字符串,R0 传递参数

        ……

MY_MUMBER

        DCD        0x12345678

HELLO_TEXT

        DCB        "Hello\n",0

        请注意:不同汇编器的指示字和语法都可以不同。上述示例代码都是按 ARM 汇编器的语法格式写的。如果使用其他汇编器,还需要根据它附带的示例代码去写。

汇编语言:后缀的使用

        在 ARM 处理器中,指令可以带有后缀。

        在 Cortex-M3 中,对条件后缀的使用有限制,只有转移指令(B指令)才可以随意使用。对于其他指令,CM3 引入了 IF-THEN 指令块,在这个块中才可以就加后缀,且必须加以后缀。IF-THEN 块由 IT 指令定义。

指令集

        在介绍指令之前,先简单地介绍一下 Cortex-M3 中支持的算术与逻辑标志。

APSR中的 5 个标志位

        N:负数标志(Negative)

        Z:零结果标志(Zero)

        C:进位/错位标志(Carry)

        V:溢出标志(0Verflow)

        S:饱和标志(Saturation),它不做条件转移的依据

16 位数据操作指令

16 位转移指令

16 位存储器数据传送指令

其他16位指令

32 位数据操作指令

32 位存储器数据传送指令

32 位转移指令

其他 32 位指令

1.2 任务调度器开启函数分析

        在前面的所有例程中我们都是在 main() 函数中先创建一个开始任务 start_task,后面紧接着调用函数 vTaskStartScheduler()这个函数的功能就是开启任务调度器的,这个函数在文件 tasks.c 中有定义,缩减后的函数代码如下:

void vTaskStartScheduler( void ) 
{ 
 BaseType_t xReturn; 
 
 xReturn = xTaskCreate( prvIdleTask,             (1) 
                        "IDLE", 
                        configMINIMAL_STACK_SIZE, 
                        ( void * ) NULL, 
                        ( tskIDLE_PRIORITY | portPRIVILEGE_BIT ), 
                        &xIdleTaskHandle ); 
 
 #if ( configUSE_TIMERS == 1 ) //使用软件定时器使能 
 { 
     if( xReturn == pdPASS ) 
     { 
         xReturn = xTimerCreateTimerTask();             (2) 
     } 
     else 
     { 
         mtCOVERAGE_TEST_MARKER(); 
     } 
 } 
 #endif /* configUSE_TIMERS */ 
 
 if( xReturn == pdPASS ) //空闲任务和定时器任务创建成功。 
 { 
     portDISABLE_INTERRUPTS();                     (3) 
 
 #if ( configUSE_NEWLIB_REENTRANT == 1 ) //使能 NEWLIB 
 { 
     _impure_ptr = &( pxCurrentTCB->xNewLib_reent ); 
 } 
 #endif /* configUSE_NEWLIB_REENTRANT */ 
 
 xNextTaskUnblockTime = portMAX_DELAY; 
 xSchedulerRunning = pdTRUE;                     (4) 
 xTickCount = ( TickType_t ) 0U; 
 
 portCONFIGURE_TIMER_FOR_RUN_TIME_STATS();        (5) 
 
 if( xPortStartScheduler() != pdFALSE )           (6) 
 { 
     //如果调度器启动成功的话就不会运行到这里,函数不会有返回值的 
 } 
 else 
 { 
     //不会运行到这里,除非调用函数 xTaskEndScheduler()。 
 } 
 } 
 else 
 { 
     //程序运行到这里只能说明一点,那就是系统内核没有启动成功,导致的原因是在创建

//空闲任务或者定时器任务的时候没有足够的内存。 
     configASSERT( xReturn != errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY ); 
 } 
 
 //防止编译器报错,比如宏 INCLUDE_xTaskGetIdleTaskHandle 定义为 0 的话编译器就会提

//示 xIdleTaskHandle 未使用。 
 ( void ) xIdleTaskHandle; 
}

(1)、开启任务调度首先会创建空闲任务,如果使用静态内存的话使用函数 xTaskCreateStatic() 来创建空闲任务,优先级为 tskIDLE_PRIORITY,宏 tskIDLE_PRIORITY 为 0,也就是说空闲任务的优先级最低

(2)、如果使用软件定时器的话还需要通过函数 xTimerCreateTimerTask() 来创建定时器服务任务。定时器服务任务的具体创建过程是在函数 xTimerCreateTimerTask() 中完成的。

(3)、关闭中断,在 SVC 中断服务函数 vPortSVCHandler() 中会打开中断。

(4)、变量 xSchedulerRunning 设置为 pdTRUE,表示调度器开始运行。

(5)、当宏 configGENERATE_RUN_TIME_STATS 为 1 的时候说明使能时间统计功能,此时需要用户实现宏 portCONFIGURE_TIMER_FOR_RUN_TIME_STATS,此宏用来配置一个定时器/计数器。

(6)、调用函数 xPortStartScheduler() 来初始化跟调度器启动有关的硬件,比如滴答定时器、FPU单元和 PendSV 中断等等。

1.2.1 内核相关硬件初始化函数分析

        FreeRTOS 系统时钟是由滴答定时器来提供的,而且任务切换也会用到 PendSV 中断,这些硬件的初始化由函数 xPortStartScheduler() 来完成,缩减后的函数代码如下:

BaseType_t xPortStartScheduler( void ) 
{ 
     /******************************************************************/ 
     /****************此处省略一大堆的条件编译代码**********************/ 
     /*****************************************************************/ 
     portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;          (1) 
     portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;         (2) 
     vPortSetupTimerInterrupt();                           (3) 
     uxCriticalNesting = 0;                                (4) 
     prvEnableVFP();                                       (5) 
     *( portFPCCR ) |= portASPEN_AND_LSPEN_BITS;           (6) 
     prvStartFirstTask();                                  (7) 
 
     //代码正常执行的话是不会到这里的! 
     return 0; 
}

(1)、设置 PendSV 的中断优先级,为最低优先级。

(2)、设置滴答定时器的中断优先级,为最低优先级。

(3)、调用函数 vPortSetupTimerInterrupt() 来设置滴答定时器的定时周期,并且使能滴答定时器的中断。

(4)、初始化临界区嵌套计数器。

(5)、调用函数 prvEnableVFP() 使能 FPU。

(6)、设置寄存器 FPCCR 的 bit31 和 bit30 都为 1 ,这样 S0~S15 和 FPSCR 寄存器在异常入口和退出时的状态会自动保存和恢复。并且异常流程使用惰性压栈的特性以保证中断等待。

1.2.2 使能 FPU 函数分析

        在函数 xPortStartScheduler() 中会通过调用 prvEnableVFP() 来使能 FPU,这个函数是汇编形式的,在文件 port.c 中有定义。

__asm void prvEnableVFP( void ) 
{ 
     PRESERVE8 
 
     ldr.w r0, =0xE000ED88 ;R0=0XE000ED88             (1) 
     ldr r1, [r0] ;从 R0 的地址读取数据赋给 R1          (2) 
 
     orr r1, r1, #( 0xf << 20 ) ;R1=R1|(0xf<<20)      (3) 
     str r1, [r0] ;R1 中的值写入 R0 保存的地址中        (4) 
     bx r14                                           (5) 
     nop 
}

(1)、利用寄存器 CPACR 可以使能或禁止 FPU ,此寄存器的地址为 0xE000ED88,此寄存器的地址为 0xE000ED88,此寄存器的 CP10(bit20 和 bit21)和 CP11(bit22 和 bit23)用于控制FPU。通常将这个 4 个 bit 都设置为 1 来开启 FPU,表示全访问。此行代码将地址 0xE000ED88 保存在寄存器 R0 中

(2)、读取 R0 中保存的存储地址处的数据,也就是 CPACR 寄存器的值,并将结果保存在 R1 寄存器中

(3)、R1 中的值与(0xf<<20)进行按位或运算,也就是 R1 = R1 | 0x00F00000。此时 R1 所保存的值的 bit20 ~ bit23 就都为 1 了,将这个值写入寄存器 CPACR 中就可开启 FPU。

(4)、将 R1 中的值写入 R0 中保存的地址处,也就是寄存器 CPACR 中

(5)、函数返回。bx 为间接跳转指令,一般为 BX<Rm>,也就是跳转到存放在 Rm 中的地址处,此处是跳转到 R14 存放的地址处。R14 寄存器也叫做链接寄存(LR),也可以用 LR 表示。

这个寄存器用于函数或子程序调用时返回地址的保存。

1.2.3 启动第一个任务

        经过上面的操作以后我们就可以启动第一个任务了,函数 prvStartFirstTask() 用于启动第一个任务,这是一个汇编函数,函数源码如下:

__asm void prvStartFirstTask( void ) 
{ 
     PRESERVE8 
 
     ldr r0, =0xE000ED08 ;R0=0XE000ED08                 (1) 
     ldr r0, [r0] ;取 R0 所保存的地址处的值赋给 R0        (2) 
     ldr r0, [r0] ;获取 MSP 初始值                       (3) 
 
     msr msp, r0 ;复位 MSP                              (4)
     cpsie I ;使能中断(清除 PRIMASK)                     (5) 
     cpsie f ;使能中断(清除 FAULTMASK)                   (6) 
     dsb ;数据同步屏障                                   (7) 
     isb ;指令同步屏障                                   (8) 
 
     svc 0 ;触发 SVC 中断(异常)                          (9) 
     nop 
     nop 
} 

(1)、将 0xE000ED08 保存在寄存器 R0 中。一般来说向量表应该是从起始地址(0x00000000)开始存储的,不过,有些应用可能需要在运行时修改或重定义向量表,Cortex-M 处理器为此提供了一个叫做向量表重定位的特性。向量表重定位特性提供了一个名为向量表偏移寄存器(VTOR)的可编程寄存器。VTOR 寄存器的地址就是 0xE000ED08,通过这个寄存器可以重新定义向量表。

SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET;        //VTOR = 0x08000000+0x00

通过上面一行代码就将向量表开始地址重新定义到了 0x08000000,向量表的起始地址存储的就是 MSP 初始值。

(2)、读取 R0 中存储的地址处的数据并将其保存在 R0 寄存器,也就是读取寄存器 VTOR 中的值,并将其保存在 R0 寄存器中。这一行代码执行完以后 R0 的值应该是 0x08000000。

(3)、读取 R0 中存储的地址处的数据并将其保存在 R0 寄存器,也就是读取地址 0x08000000 处存储的数据,并将其保存在 R0 寄存器中

(4)、复位 MSP,R0 中保存了 MSP 的初始值,将其赋值给 MSP 就相当于复位 MSP

(5)和(6)、使能中断

(7)和(8)、数据同步和指令同步屏障

(9)、调用 SVC 指令触发 SVC 中断,SVC 也叫做请求管理调用,SVC 和 PendSV 异常对于 OS 的设计来说非常重要。SVC 异常由 SVC 指令触发

1.2.4 SVC 中断服务函数

        在函数 prvStartFirstTask() 中通过调用 SVC 指令触发了 SVC 中断,而第一个任务的启动就是在 SVC 中断服务函数中完成的,SVC 中断服务函数应该为 SVC_Handler(),但是 FreeRTOSConfig.h 中通过 #define 的方式重新定义为了 xportPendSVHandler(),如下:

#define xPortPendSVHandler    PendSV_Handler

        函数 vPortSVCHandler() 在文件 port.c 中定义,这个函数也是用汇编写的,函数源码如下:

__asm void vPortSVCHandler( void ) 
{
    PRESERVE8 
 
    ldr r3, =pxCurrentTCB ;R3=pxCurrentTCB 的地址                 (1) 
    ldr r1, [r3] ;取 R3 所保存的地址处的值赋给 R1                   (2) 
    ldr r0, [r1] ;取 R1 所保存的地址处的值赋给 R0                   (3) 
 
    ldmia r0!, {r4-r11, r14} ;出栈 ,R4~R11 和 R14                 (4) 
    msr psp, r0 ;进程栈指针 PSP 设置为任务的堆栈                    (5) 
    isb ;指令同步屏障 
    mov r0, #0 ;R0=0                                              (6) 
    msr basepri, r0 ;寄存器 basepri=0,开启中断                     (7) 
    bx r14                                                        (8) 
}

(1)、获取 pxCurrentTCB 指针的存储地址,pxCurrentTCB 是一个指向 TCB_t 的指针,这个指针永远指向正在运行的任务

(2)、上一步已经获得正在运行的任务的地址,地址已经赋值给了 R3,这一步取 R3 所保存的地址处的值赋给 R1

(3)、取 R1 所保存的地址处的值赋给 R0,我们知道任务控制块的第一个字段就是任务堆栈的栈顶指针 pxTopOfStack 所指向的位置,所以读取任务控制块所在的首地址得到的就是栈顶指针所指向的地址。

        可以看出,(1)、(2)和(3)的目的就是获取要切换到的这个任务的任务栈顶指针,因为任务所对应的寄存器值,也就是现场都保存在任务的任务堆栈中,所以需要获取栈顶指针来恢复这些寄存器值!(简单来说,就是实时操作系统中最重要的就是多任务操作,同一时间只有一个任务会运行,但是由于每个任务对应的时间片非常的短,为了保证下一次循环到同一个任务时,任务是接上上一次运行的,所以需要将上一次任务结束时的寄存器值保存到堆栈中,这些寄存器值也称作现场,所以下一次需要获取栈顶指针来恢复这些寄存器值)

(4)、R4~R11,R14 这些寄存器出栈。这里使用了指令 LDMIA,LDMIA 指令是多加载/存储指令,这里使用的是具有回写的多加载/存储访问指令,用法如下:

LDMIA    Rn!    {reg list}

//表示从 Rn 指定的存储器位置读取多个字,地址在每次读取后增加(IA),Rn 在传输完成以后写回。
//对于 STM32 来说地址一次增加 4 字节,比如如下代码:

LDR    R0,    =0x800
LDMIA  R0!,  {R2~R4}   

//上面两行代码就是将 0x800 地址的数据赋值给寄存器 R2,0x804 地址的数据赋值给寄存器 R3,0x8008 地址的数据赋值给 R4 寄存器
//重点:此时 R0 为 0x800A!

        通过这一步我们就从任务堆栈中将 R4~R11,R14 这几个寄存器的值给恢复了,注意 R14 值为 0xFFFFFFFD,这个值就是我们在初始化任务堆栈的时候保存的 EXC_RETURN 的值!

        这里之所以 R0~R3,R12,RC,xPSR 这些寄存器没有恢复,是因为这些寄存器在退出中断的时候 MCU 自动出栈恢复,而 R4~R11 需要由用户手动出栈。如果使用到 FPU 的话还要考虑到 FPU 寄存器。

        从上图中可以看出恢复 R4~R11 和 R14 以后堆栈的栈顶指针应该指向地址 0x20000E60,也就是保存寄存器 R0 值的存储地址。退出中断服务函数以后进程栈指针 PSP 应该是从这个地址开始恢复其他的寄存器值。

(5)、设置进程栈指针 PSP,PSP=R0=0x20000E60

(6)、设置寄存器 R0 的值为 0

(7)、设置寄存器 BASEPRI 为 R0,也就是 0,打开中断

(8)、执行此行代码以后硬件自动恢复寄存器 R0~R3、R12、LR、PC 和 xPSR 的值,堆栈使用进程栈 PSP,然后执行寄存器 PC 中保存的任务函数。至此,FreeRTOS 的任务调度器正式开始运行!

1.2.5 空闲任务

        在开启任务调度器 vTaskStartScheduler() 时说过,调用该函数首先就会创建一个名为 “IDLE” 的任务,这个任务叫做空闲任务。顾名思义,空闲任务就是空闲的时候运行的任务,也就是系统中其他的任务由于各种原因不能运行的时候空闲任务就在运行。空闲任务是 FreeRTOS 系统自动创建的,不需要用户手动创建。任务调度器启动以后就必须有一个任务运行!但是空闲任务不仅仅是为了满足任务调度器启动以后至少有一个任务运行而创建的,空闲任务中还会去做一些其他的事情,比如说:

        1. 判断系统是否有任务删除,如果有的话就在空闲任务中释放被删除任务的任务堆栈和任务控制块的内存。

        2. 运行用户设置的空闲任务钩子函数。

        3. 判断是否开启低功耗 tickless 模式,如果开启的话还需要做相应的处理。

        空闲任务的任务优先级是最低的,为 0 ,任务函数为 prvIldleTask()。

2. 任务创建过程分析

2.1 任务创建函数分析

        前面学了任务创建可以使用动态方法和静态方法,它们分别使用函数 xTaskCreate() 和 xTaskCreateStatic() 。本节我们就以函数 xTaskCreate() 为例来分析一下 FreeRTOS 的任务创建过程。

BaseType_t xTaskCreate(TaskFunction_t pxTaskCode, 
                       const char * const pcName, 
                       const uint16_t usStackDepth, 
                       void * const pvParameters, 
                       UBaseType_t uxPriority, 
                       TaskHandle_t * const pxCreatedTask ) 
{ 
     TCB_t *pxNewTCB; 
     BaseType_t xReturn; 
 
     /********************************************************************/ 
     /***************使用条件编译的向上增长堆栈相关代码省略***************/ 
     /********************************************************************/ 
 
     StackType_t *pxStack; 
     pxStack = ( StackType_t * ) pvPortMalloc( ( ( ( size_t ) usStackDepth ) *\ (1) 
     sizeof( StackType_t ) ) ); 
 
if( pxStack != NULL ) 
{ 
    pxNewTCB = ( TCB_t * ) pvPortMalloc( sizeof( TCB_t ) );               (2) 
    if( pxNewTCB != NULL ) 
    { 
        pxNewTCB->pxStack = pxStack;                                 (3) 
    } 
    else 
    { 
        vPortFree( pxStack );                             (4) 
    } 
} 
else 
{ 
    pxNewTCB = NULL; 
} 
 
if( pxNewTCB != NULL )
{ 
    #if( tskSTATIC_AND_DYNAMIC_ALLOCATION_POSSIBLE != 0 ) 
    { 
        pxNewTCB->ucStaticallyAllocated =\                 (5) 
        tskDYNAMICALLY_ALLOCATED_STACK_AND_TCB; 
    } 
    #endif /* configSUPPORT_STATIC_ALLOCATION */ 
 
    prvInitialiseNewTask( pxTaskCode, pcName, ( uint32_t ) usStackDepth, \     (6)                 
    pvParameters, uxPriority, pxCreatedTask, pxNewTCB, NULL ); 
    prvAddNewTaskToReadyList( pxNewTCB );                     (7) 
    xReturn = pdPASS; 
} 
else 
{ 
    xReturn = errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY; 
} 
 
return xReturn; 
}

(1)、使用函数 pvPortMalloc() 给任务的任务堆栈申请内存,申请内存的时候会做字节对齐处理。

(2)、如果堆栈的内存申请成功的话就接着给任务控制块申请内存,同样使用函数 pvPortMalloc()。

(3)、任务控制块内存申请成功的话就初始化内存控制块中的任务堆栈字段 pxStack,使用(1)中申请到的任务堆栈。

(4)、如果任务控制块内存申请失败的话就释放前面已经申请成功的任务堆栈的内存。

(5)、标记任务堆栈和任务控制块是使用动态内存分配方法得到的。

(6)、使用函数 prvInitialiseNewTask() 初始化任务,这个函数完成对任务控制块中各个字段的初始化工作!

(7)、使用函数 prvAddNewTaskToReadyList() 将新创建的任务加入到就绪列表中。

2.2 任务初始化函数分析

        函数 prvInitialiseNewTask() 用于完成对任务的初始化,缩减后的函数源码如下:

static void prvInitialiseNewTask( TaskFunction_t pxTaskCode, 
                                  const char * const pcName, 
                                  const uint32_t ulStackDepth, 
                                  void * const pvParameters, 
                                  UBaseType_t uxPriority, 
                                  TaskHandle_t * const pxCreatedTask, 
                                  TCB_t * pxNewTCB, 
                                  const MemoryRegion_t * const xRegions ) 
{
StackType_t *pxTopOfStack; 
UBaseType_t x; 
 
#if( ( configCHECK_FOR_STACK_OVERFLOW > 1 ) || ( configUSE_TRACE_FACILITY ==\ 
1 ) || ( INCLUDE_uxTaskGetStackHighWaterMark == 1 ) ) 
{ 
    ( void ) memset( pxNewTCB->pxStack, ( int ) tskSTACK_FILL_BYTE,\       (1) 
    ( size_t ) ulStackDepth * sizeof( StackType_t ) ); 
} 
#endif 
 
pxTopOfStack = pxNewTCB->pxStack + ( ulStackDepth - ( uint32_t ) 1 );       (2) 
pxTopOfStack = ( StackType_t * ) ( ( ( portPOINTER_SIZE_TYPE ) pxTopOfStack ) &\ 
( ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) ) ); 
 
for( x = ( UBaseType_t ) 0; x < ( UBaseType_t ) configMAX_TASK_NAME_LEN; x++ ) 
{ 
pxNewTCB->pcTaskName[ x ] = pcName[ x ];                                    (3) 
if( pcName[ x ] == 0x00 ) 
{ 
    break; 
} 
else 
{ 
    mtCOVERAGE_TEST_MARKER(); 
} 
} 
pxNewTCB->pcTaskName[ configMAX_TASK_NAME_LEN - 1 ] = '\0';                 (4) 
 
if( uxPriority >= ( UBaseType_t ) configMAX_PRIORITIES )                     (5) 
{ 
    uxPriority = ( UBaseType_t ) configMAX_PRIORITIES - ( UBaseType_t ) 1U; 
} 
else 
{ 
    mtCOVERAGE_TEST_MARKER(); 
} 
pxNewTCB->uxPriority = uxPriority;                                         (6) 
 
#if ( configUSE_MUTEXES == 1 )                                             (7) 
{ 
    pxNewTCB->uxBasePriority = uxPriority; 
    pxNewTCB->uxMutexesHeld = 0; 
} 
 #endif /* configUSE_MUTEXES */ 
 
vListInitialiseItem( &( pxNewTCB->xStateListItem ) );                     (8) 
vListInitialiseItem( &( pxNewTCB->xEventListItem ) );                     (9) 
 
listSET_LIST_ITEM_OWNER( &( pxNewTCB->xStateListItem ), pxNewTCB );     (10) 
listSET_LIST_ITEM_VALUE( &( pxNewTCB->xEventListItem ), \                 (11) 
( TickType_t ) configMAX_PRIORITIES - ( TickType_t ) uxPriority ); 
listSET_LIST_ITEM_OWNER( &( pxNewTCB->xEventListItem ), pxNewTCB );     (12) 
 
#if ( portCRITICAL_NESTING_IN_TCB == 1 ) //使能临界区嵌套 
{ 
    pxNewTCB->uxCriticalNesting = ( UBaseType_t ) 0U; 
} 
#endif /* portCRITICAL_NESTING_IN_TCB */ 
 
#if ( configUSE_APPLICATION_TASK_TAG == 1 ) //使能任务标签功能 
{ 
    pxNewTCB->pxTaskTag = NULL; 
} 
#endif /* configUSE_APPLICATION_TASK_TAG */ 
 
#if ( configGENERATE_RUN_TIME_STATS == 1 ) //使能时间统计功能 
{ 
    pxNewTCB->ulRunTimeCounter = 0UL; 
} 
#endif /* configGENERATE_RUN_TIME_STATS */ 
 
#if( configNUM_THREAD_LOCAL_STORAGE_POINTERS != 0 ) 
{ 
    for( x = 0; x < ( UBaseType_t ) configNUM_THREAD_LOCAL_STORAGE_POINTERS;\ 
    x++ ) 
{ 
    pxNewTCB->pvThreadLocalStoragePointers[ x ] = NULL;                     (12) 
} 
} 
#endif 
 
#if ( configUSE_TASK_NOTIFICATIONS == 1 ) //使能任务通知功能 
{ 
    pxNewTCB->ulNotifiedValue = 0; 
    pxNewTCB->ucNotifyState = taskNOT_WAITING_NOTIFICATION; 
} 
#endif 
 
#if ( configUSE_NEWLIB_REENTRANT == 1 ) //使能 NEWLIB 
{ 
    _REENT_INIT_PTR( ( &( pxNewTCB->xNewLib_reent ) ) ); 
} 
#endif 
 
#if( INCLUDE_xTaskAbortDelay == 1 ) //使能函数 xTaskAbortDelay() 
{ 
    pxNewTCB->ucDelayAborted = pdFALSE; 
} 
#endif 
 
pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode,\     (13) 

pvParameters ); 
if( ( void * ) pxCreatedTask != NULL ) 
{ 
    *pxCreatedTask = ( TaskHandle_t ) pxNewTCB;                         (14) 
} 
else 
{ 
    mtCOVERAGE_TEST_MARKER(); 
} 
} 

(1)、如果使能了堆栈溢出检测功能或者追踪功能的话就使用一个定值 tskSTACK_FILL_BYTE 来填充任务堆栈,这个值为 0xa5U。

(2)、计算堆栈栈顶 pxTopOfStack,后面初始化堆栈的时候需要用到。

(3)、保存任务的任务名。

(4)、任务名数组添加字符串结束符 ‘\0’。

(5)、判断任务优先级是否合法,如果设置优先级大于 configMAX_PRIORITIES,则将优先级修改为 configMAX_PRIORITIES-1。

(6)、初始化任务控制块的优先级字段 uxPriority。

(7)、使能了互斥信号量功能,需要初始化相应的字段。

(8)和(9)、初始化列表项 xStateListItem 和 xEventListItem,任务控制块结构体中有两个列表项,这里对这两个列表项做初始化。

(10)和(12)、设置列表项 xStateListItem 和 xEventListItem 属于当前任务的任务控制块,也就是设置这两个列表项的字段 pvOwner 为新创建的任务的任务控制块。

(11)、设置列表项 xEventListItem 的字段 xItemValue 为 configMAX_PRIORITIES - uxPriority,比如说当前的任务优先级为 3,最大优先级为 32,那么 xItemValue 就为 32-3=29,那么 xItemValue 就为 32-3=29,这就意味着 xItemValue 值越大,优先级就越小。列表和列表项中我们提到过,列表的插入是按照 xItemValue 的值升序排列的。

(12)、初始化线程本地存储指针。

(13)、调用函数 pxPortInitialiseStack() 初始化任务堆栈。

(14)、生成任务句柄,返回给参数 pxCreatedTask,从这里可以看出任务句柄其实就是任务控制块。

2.3 任务堆栈初始化函数分析

        在任务初始化函数中会对任务堆栈初始化,这个过程通过调用函数 pxPortInitialiseStack() 来完成,函数 pxPortInitialiseStack() 就是堆栈初始化函数,函数源码如下:

StackType_t *pxPortInitialiseStack( StackType_t * pxTopOfStack, 
                                    TaskFunction_t pxCode, 
                                    void * pvParameters ) 
{ 
    pxTopOfStack--; 
    *pxTopOfStack = portINITIAL_XPSR;                                     (1) 
 
    pxTopOfStack--; 
    *pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK;  (2) 
 
    pxTopOfStack--; 
    *pxTopOfStack = ( StackType_t ) prvTaskExitError;                     (3) 
 
    pxTopOfStack -= 5;                                                    (4) 
    *pxTopOfStack = ( StackType_t ) pvParameters;                         (5) 
 
    pxTopOfStack--; 
    *pxTopOfStack = portINITIAL_EXEC_RETURN;                              (6) 
 
    pxTopOfStack -= 8;                                                    (7) 
    return pxTopOfStack; 
} 

        堆栈是用来进行上下文切换的时候保存现场的,一般新创建好一个堆栈以后会对其先进行初始化处理,也就是对 Cortex-M 内核的某些寄存器赋初值。这些初值就保存在任务堆栈中,保存的顺序按照:xPSR、R15(PC)、R14(LR)、R12、R3~R0、R11~R14

        (1)、寄存器 xPSR 值为 portINITIAL_XPSR,其值为 0x01000000。xPSR 是 Cortex-M4 的一个内核寄存器,叫做程序状态寄存器,0x01000000 表示这个寄存器的 bit24 为 1,表示处于 Thumb 状态,即使用的 Thumb 指令。

        (2)、寄存器 PC 初始化为任务函数 pxCode

        (3)、寄存器 LR 初始化为函数 prvTaskExitError

        (4)、跳过 4 个寄存器,R12,R3,R2,R1,这四个寄存器不初始化

        (5)、寄存器 R0 初始化为 pvParameters,一般情况下,函数调用会将 R0~R3 作为输入参数,R0 也可用作返回结果,如果返回值为 64 位,则 R1 也会用于返回结果,这里的 pvParameters 是作为任务函数的参数,保存在寄存器 R0 中

        (6)、保存 EXC_RETURN 值,用于退出 SVC 或 PendSV 中断的时候处理器应该处于什么状态。处理器进入异常或中断服务程序(ISR)时,链接寄存器 R14(LR)的数值会被更新为 EXC_RETURN 数值,之后该数值会在异常处理结束时触发异常返回。这里人为的设置为 0xFFFFFFD ,表示退出异常以后 CPU 进入线程模式并且使用进程栈!(简单的说还是为了退出中断以后保持之前的状态继续运行)

        (7)、跳过 8 个寄存器,R11、R10、R8、R7、R6、R5、R4

2.4 添加任务到就绪列表

        任务创建完成以后就会被添加到就绪列表中,FreeRTOS 使用不同的列表表示任务的不同状态,在文件 tasks.c 中就定义了多个列表来完成不同的功能,这些列表如下:

PRIVILEGED_DATA static List_t pxReadyTasksLists[ configMAX_PRIORITIES ]; 
PRIVILEGED_DATA static List_t xDelayedTaskList1; 
PRIVILEGED_DATA static List_t xDelayedTaskList2; 
PRIVILEGED_DATA static List_t * volatile pxDelayedTaskList; 
PRIVILEGED_DATA static List_t * volatile pxOverflowDelayedTaskList; 
PRIVILEGED_DATA static List_t xPendingReadyList;

        列表数组 pxReadyTasksLists[] 就是任务就绪列表,数组大小为 configMAX_PRIORITIES,也就是说一个优先级一个列表,这样相同优先级的任务就使用一个列表

        将一个新创建的任务添加到就绪列表中通过函数 prvAddNewTaskToReadyList() 来完成,函数如下:

static void prvAddNewTaskToReadyList( TCB_t *pxNewTCB ) 
{ 
taskENTER_CRITICAL(); 
{
uxCurrentNumberOfTasks++;                                         (1) 
if( pxCurrentTCB == NULL )//正在运行任务块为 NULL,说明没有任务运行! 
{ 
    pxCurrentTCB = pxNewTCB;//将新任务的任务控制块赋值给 pxCurrentTCB 
    //新创建的任务是第一个任务!!! 
    if( uxCurrentNumberOfTasks == ( UBaseType_t ) 1 ) 
    { 
        prvInitialiseTaskLists();                                         (2) 
    } 
    else 
    { 
        mtCOVERAGE_TEST_MARKER(); 
    } 
} 
else 
{ 
    if( xSchedulerRunning == pdFALSE ) 
    { 
        //新任务的任务优先级比正在运行的任务优先级高。 
        if( pxCurrentTCB->uxPriority <= pxNewTCB->uxPriority ) 
        { 
            pxCurrentTCB = pxNewTCB;                                 (3) 
        } 
        else 
        { 
            mtCOVERAGE_TEST_MARKER(); 
        } 
    } 
    else 
    { 
        mtCOVERAGE_TEST_MARKER(); 
    } 
} 
 
uxTaskNumber++; //uxTaskNumber 加一,用作任务控制块编号。 
#if ( configUSE_TRACE_FACILITY == 1 ) 
{ 
    pxNewTCB->uxTCBNumber = uxTaskNumber; 
} 
#endif /* configUSE_TRACE_FACILITY */ 
prvAddTaskToReadyList( pxNewTCB );                                 (4) 
} 
    taskEXIT_CRITICAL(); 
if( xSchedulerRunning != pdFALSE ) 
{ 
    //新任务优先级比正在运行的任务优先级高 
    if( pxCurrentTCB->uxPriority < pxNewTCB->uxPriority ) 
    { 
        taskYIELD_IF_USING_PREEMPTION();                             (5) 
    } 
    else 
    { 
        mtCOVERAGE_TEST_MARKER(); 
    } 
} 
else 
{ 
    mtCOVERAGE_TEST_MARKER(); 
} 
} 

(1)、变量 uxCurrentNumberOfTasks 为全局变量,用来统计任务数量

(2)、变量 uxCurrentNumberOfTasks 为 1 说明正在创建的任务是第一个任务!那么就需要先初始化相应的列表,通过调用函数 prvInitialiseTaskLists() 来初始化相应的列表。

(3)、新创建的任务优先级比正在运行的任务优先级高,所以需要修改 pxCurrentTCB 为新建任务的任务控制块

(4)、调用函数 prvAddTaskToReadyList() 将任务添加到就绪列表中,这个其实是个宏,如下:

#define prvAddTaskToReadyList( pxTCB )                                \ 
    traceMOVED_TASK_TO_READY_STATE( pxTCB );                          \ 
    taskRECORD_READY_PRIORITY( ( pxTCB )->uxPriority );               \ 
    vListInsertEnd( &( pxReadyTasksLists[ ( pxTCB )->uxPriority ] ),  \ 
                    &( ( pxTCB )->xStateListItem ) );                 \ 
    tracePOST_MOVED_TASK_TO_READY_STATE( pxTCB )

        其中宏 portRECORD_READY_PRIORITY() 用来记录处于就绪态的任务,具体是通过操作全局变量 uxTopReadyPriority 来实现的。这个变量用来查找处于就绪态的优先级最高任务,接下来使用函数 vListInsertEnd() 将任务添加到就绪列表末尾。

(5)、如果新任务的任务优先级最高,而且调度器已经开始正常运行了,那么就调用函数 taskYIELD_IF_USING_PREEMPTION() 完成一次任务切换。

3. 任务删除过程分析

        在之前的学习过程中,我们已经学习了如何使用 FreeRTOS 的任务删除函数 vTaskDelete() ,这里我们详细了解一下这个函数的具体实现过程:

void vTaskDelete( TaskHandle_t xTaskToDelete )
{ 
TCB_t *pxTCB; 
 
taskENTER_CRITICAL(); 
{ 
    //如果参数为 NULL 的话那么说明调用函数 vTaskDelete()的任务要删除自身。 
    pxTCB = prvGetTCBFromHandle( xTaskToDelete );                         (1) 
 
    //将任务从就绪列表中删除。 
    if( uxListRemove( &( pxTCB->xStateListItem ) ) == ( UBaseType_t )     0 )     (2) 
    { 
        taskRESET_READY_PRIORITY( pxTCB->uxPriority ); 
    } 
    else 
    { 
        mtCOVERAGE_TEST_MARKER(); 
    } 

    //任务是否在等待某个事件? 
    if( listLIST_ITEM_CONTAINER( &( pxTCB->xEventListItem ) ) != NULL )     (3) 
    { 
        ( void ) uxListRemove( &( pxTCB->xEventListItem ) ); 
    } 
    else 
    { 
        mtCOVERAGE_TEST_MARKER(); 
    } 
 
    uxTaskNumber++; 
 
    if( pxTCB == pxCurrentTCB )                                         (4) 
    { 
        vListInsertEnd( &xTasksWaitingTermination, &( pxTCB->\           (5) 
                        xStateListItem ) ); 
 
        ++uxDeletedTasksWaitingCleanUp;                                 (6) 
 
        portPRE_TASK_DELETE_HOOK( pxTCB, &xYieldPending );               (7) 
    } 
    else 
    { 
        --uxCurrentNumberOfTasks;                                         (8) 
        prvDeleteTCB( pxTCB );                                          (9) 
        prvResetNextTaskUnblockTime();                                    (10) 
    } 
 
    traceTASK_DELETE( pxTCB ); 
} 
taskEXIT_CRITICAL(); 
 
//如果删除的是正在运行的任务那么就需要强制进行一次任务切换。 
if( xSchedulerRunning != pdFALSE ) 
{ 
    if( pxTCB == pxCurrentTCB ) 
    { 
        configASSERT( uxSchedulerSuspended == 0 ); 
        portYIELD_WITHIN_API();                                         (11) 
    } 
    else 
    { 
        mtCOVERAGE_TEST_MARKER(); 
    } 
} 
} 

        (1)、调用函数 prvGetTCBFromHandle() 获取要删除任务的任务控制块,参数为任务句柄。如果参数为当前正在执行的任务句柄那么返回值就为 NULL。

        (2)、将任务从任务就绪列表中删除。

        (3)、查看任务是否正在等待某个事件(如信号量、队列等),因为如果任务等待某个事件的话这个任务会被放到相应的列表中,这里需要将其从相应的列表中删除。

        (4)、要删除的是当前正在运行的任务。

        (5)、要删除任务,那么任务的任务控制块和任务堆栈所占用的内存肯定要被释放掉(如果使 用动态方法创建的任务),但是当前任务正在运行,显然任务控制块和任务堆栈的内存不能被立 即释放掉!必须等到当前任务运行完成才能释放相应的内存,所以需要打一个“标记”,标记出 有任务需要处理。这里将当前任务添加到列表 xTasksWaitingTermination 中,如果有任务要删除 自身的话都会被添加到列表 xTasksWaitingTermination 中。动态开辟的内存会在空闲任务中被依次释放掉!!!

        (6)、uxDeletedTasksWaitingCleanUp 是一个全局变量,用来记录有多少个任务需要释放内存。

        (7)、调用任务删除钩子函数,钩子函数的具体内容需要用户自行实现。

        (8)、删除的是别的任务,变量 uxCurrentNumberOfTasks 减一,也就是当前任务数减一。

        (9)、因为是删除别的任务,所以可以直接调用函数 prvDeleteTCB()删除任务控制块。

        (10)、重新计算一下还要多长时间执行下一个任务,也就是下一个任务的解锁时间,防止有 任务的解锁时间参考了刚刚被删除的那个任务。

        (11)、如果删除的是正在运行的任务那么删除完以后肯定需要强制进行一次任务切换。

4. 任务挂起过程分析

        挂起任务使用函数 vTaskSuspend() ,函数源码如下:

void vTaskSuspend( TaskHandle_t xTaskToSuspend ) 
{ 
TCB_t *pxTCB; 
 
taskENTER_CRITICAL(); 
{ 
    //如果参数为 NULL 的话说明挂起自身 
    pxTCB = prvGetTCBFromHandle( xTaskToSuspend );                     (1) 
    traceTASK_SUSPEND( pxTCB ); 
 
    //将任务从就绪或者延时列表中删除,并且将任务放到挂起列表中 
    if( uxListRemove( &( pxTCB->xStateListItem ) ) == ( UBaseType_t ) 0 ) (2) 
    { 
        taskRESET_READY_PRIORITY( pxTCB->uxPriority ); 
    } 
    else 
    { 
        mtCOVERAGE_TEST_MARKER(); 
    } 
 
    //任务是否还在等待其他事件 
    if( listLIST_ITEM_CONTAINER( &( pxTCB->xEventListItem ) ) != NULL )     (3) 
    { 
        ( void ) uxListRemove( &( pxTCB->xEventListItem ) ); 
    } 
    else 
    { 
        mtCOVERAGE_TEST_MARKER(); 
    } 
 
    vListInsertEnd( &xSuspendedTaskList, &( pxTCB->xStateListItem ) );     (4) 
} 
taskEXIT_CRITICAL(); 
 
if( xSchedulerRunning != pdFALSE ) 
{ 
    taskENTER_CRITICAL(); 
{ 
    prvResetNextTaskUnblockTime();                                 (5) 
} 
    taskEXIT_CRITICAL(); 
} 
else 
{ 
    mtCOVERAGE_TEST_MARKER(); 
} 
 
if( pxTCB == pxCurrentTCB ) 
{ 
    if( xSchedulerRunning != pdFALSE ) 
    { 
        configASSERT( uxSchedulerSuspended == 0 ); 
        portYIELD_WITHIN_API();                                     (6) 
    } 
else 
{ 
    if( listCURRENT_LIST_LENGTH( &xSuspendedTaskList ) ==\         (7) 
                                 uxCurrentNumberOfTasks ) 
    { 
        pxCurrentTCB = NULL;                                        (8) 
    } 
    else 
    { 
        vTaskSwitchContext();                                       (9) 
    } 
} 
} 
else 
{ 
    mtCOVERAGE_TEST_MARKER(); 
} 
} 

        (1)、通过函数 prvGetTCBFromHandle() 获取要删除任务的任务控制块。

        (2)、将任务从任务就绪列表延时列表中删除。

        (3)、查看任务是否正在等待某个事件(如信号量、队列等),如果任务还在等待某个事件的 话就将其从相应的事件列表中删除。

        (4)、将任务添加到挂起任务列表尾,挂起任务列表为 xSuspendedTaskList,所有被挂起的任 务都会被放到这个列表中。

        (5)、重新计算一下还要多长时间执行下一个任务,也就是下一个任务的解锁时间。防止有 任务的解锁时间参考了刚刚被挂起的那个任务。

        (6)、如果刚刚挂起的任务是正在运行的任务,并且任务调度器运行正常,那么这里就需要 调用函数 portYIELD_WITHIN_API()强制进行一次任务切换。

        (7)、pxCurrentTCB 指向正在运行的任务,但是正在运行的任务要挂起了,所以必须给 pxCurrentTCB 重新找一个“对象”。也就是查找下一个将要运行的任务,本来这个工作是由任 务切换函数来完成的,但是程序运行到这一行说明任务调度器被挂起了,任务切换函数也无能 为力了,必须手动查找下一个要运行的任务了。调用函数 listCURRENT_LIST_LENGTH()判断一下系统中所有的任务是不是都被挂起了,也就是查看列表 xSuspendedTaskList 的长度是不是 等于 uxCurrentNumberOfTasks。如果等于的话就说明系统中所有的任务都被挂起了(实际上不存 在这种情况,因为最少都有一个空闲任务是可以运行的,空闲任务执行期间不会调用任何可以 阻塞或者挂起空闲任务的 API 函数,为的就是保证系统中永远都有一个可运行的任务)。

        (8)、如果所有任务都被挂起的话 pxCurrentTCB 就只能等于 NULL 了,这样当有新任务被 创建的时候 pxCurrentTCB 就可以指向这个新任务。

        (9)、有其他的没有被挂起的任务,调用 vTaskSwitchContext()获取下一个要运行的任务。

5. 任务恢复过程分析

        任务恢复函数有两个 vTaskResume() 和 xTaskResumeFromISR(),一个是用在任务中的,一个是用在中断中的,但是基本的处理过程相差不大,我们就以函数 vTaskResume() 为例来讲解一下任务恢复详细过程。

void vTaskResume( TaskHandle_t xTaskToResume ) 
{ 
TCB_t * const pxTCB = ( TCB_t * ) xTaskToResume;                     (1) 
 
configASSERT( xTaskToResume ); 
 
//函数参数不可能为 NULL。 
if( ( pxTCB != NULL ) && ( pxTCB != pxCurrentTCB ) )                 (2) 
{ 
    taskENTER_CRITICAL();                                             (3) 
{ 
if( prvTaskIsTaskSuspended( pxTCB ) != pdFALSE )                     (4) 
{ 
    traceTASK_RESUME( pxTCB ); 
 
    ( void ) uxListRemove( &( pxTCB->xStateListItem ) );             (5) 
                            prvAddTaskToReadyList( pxTCB );             (6) 
 
if( pxTCB->uxPriority >= pxCurrentTCB->uxPriority )                 (7) 
{ 
    taskYIELD_IF_USING_PREEMPTION();                                 (8) 
} 
else 
{ 
    mtCOVERAGE_TEST_MARKER(); 
} 
} 
else 
{ 
    mtCOVERAGE_TEST_MARKER(); 
} 
} 
taskEXIT_CRITICAL();                                                 (9) 
} 
else 
{ 
    mtCOVERAGE_TEST_MARKER(); 
} 
} 

        (1)、根据参数获取要恢复的任务的任务控制块,因为不存在恢复正在运行的任务这种情况 所以参数也不可能为 NULL(你强行给个为 NULL 的参数那也没办法),这里也就不需要使用函 数 prvGetTCBFromHandle()来获取要恢复的任务控制块,prvGetTCBFromHandle()会处理参数为 NULL 这种情况。

        (2)、任务控制块不能为 NULL 和 pxCurrentTCB,因为不存在说恢复当前正在运行的任务。

        (3)、调用函数 taskENTER_CRITICAL()进入临界段。

        (4)、调用函数 prvTaskIsTaskSuspended()判断要恢复的任务之前是否已经被挂起了,恢复的 肯定是被挂起的任务,没有挂起就不用恢复。

        (5)、首先将要恢复的任务从原来的列表中删除,任务被挂起以后都会放到任务挂起列表 xSuspendedTaskList 中。

        (6)、将要恢复的任务添加到就绪任务列表中。

        (7)、要恢复的任务优先级高于当前正在运行的任务优先级。

        (8)、因为要恢复的任务其优先级最高,所以需要调用函数 taskYIELD_IF_USING_PREEMPTION()来完成一次任务切换。

        (9)、调用函数 taskEXIT_CRITICAL()退出临界区。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值