FreeRTOS解析:任务切换(TASK-3)

2023-05-16

任务切换

受博客限制,如果您想获得更好的阅读体验,请前往https://github.com/Nrusher/FreeRTOS-Book或者https://gitee.com/nrush/FreeRTOS-Book下载PDF版本阅读,如果您觉得本文不错也可前往star,以示对作者的鼓励。如发现问题欢迎交流。

任务切换的目的是保证当前具有最高优先级的就绪任务获得处理器的使用权。在进行任务切换时,首先需要找到具有最高优先级的就绪任务,如果该任务不是当前正在运行的任务,需要先保存当前运行任务的堆栈,并将具有最高优先级的就绪任务堆栈恢复到处理器的堆栈中进行运行。

寻找拥有最高优先级的就绪任务

如何寻找具有最高优先级的就绪任务?根据本章前文对结构体和变量的以及任务创建过程的分析可知,所有的就绪任务都是挂载在一个链表数组pxReadyTasksLists中的,链表数组的下标代表了任务的优先级,同一个链表下挂接的任务具有相同的优先级,在任务切换过程中,它们将轮流的获得处理器的使用权。如此一来只需要在向pxReadyTasksLists添加或删除任务时,记录变动任务后处于就绪态任务的最高优先级即可获得待运行的任务。最高优先级被记录在uxTopReadyPriority这一变量中,宏函数taskRECORD_READY_PRIORITY( uxPriority )实现了在添加任务时记录最高优先级这一过程,

// 记录最高先级的就绪任务的优先级
    #define taskRECORD_READY_PRIORITY( uxPriority )\
    {                                               \
        if( ( uxPriority ) > uxTopReadyPriority )  \
        {                                           \
            uxTopReadyPriority = ( uxPriority );   \
        }                                           \
    } /* taskRECORD_READY_PRIORITY */

根据最高优先级获得要运行任务的过程由宏函数taskSELECT_HIGHEST_PRIORITY_TASK()实现,寻找到的拥有最高优先级的待运行任务会被存储到pxCurrentTCB变量中,其具体实现如下

#define taskSELECT_HIGHEST_PRIORITY_TASK()                                    \
    {                                                                            \
        UBaseType_t uxTopPriority = uxTopReadyPriority;                            \
                                                                                \
        /* step1 */                                                                \
        while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) )    \
        {                                                                        \
            configASSERT( uxTopPriority );                                        \
            --uxTopPriority;                                                    \
        }                                                                        \
                                                                                \
        /* step2 */                                                                \
        listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, &( pxReadyTasksLists[ uxTopPriority ] ) );\
        uxTopReadyPriority = uxTopPriority;                                                     \
    } /* taskSELECT_HIGHEST_PRIORITY_TASK */

插入操作导致的就绪任务优先级变动由taskRECORD_READY_PRIORITY( uxPriority )进行了处理,删除任务导致的任务优先级变化则在taskSELECT_HIGHEST_PRIORITY_TASK()中的step1被处理了。step2中listGET_OWNER_OF_NEXT_ENTRY()函数在分析链表时已经进行解析,其会循环遍历链表中的每一个任务,因此最高优先级下的所有任务会平均的共享处理器的使用权。

以上所说的是通用版本的做法,除此方法外,FreeRTOS还提供了一套经过优化的,用来记录和寻找最高优先级的方法。对于crotex-m3为内核的平台,其借助特殊指令clz(从最高位开始,计算一个整型变量0的个数),实现bit map方法,以此提高程序运行的效率,在优化版本下,上述函数的代码如下

// 经过特殊优化的方法,依赖硬件
    // 这里不在使用数值大小来表示最高优先级,而是使用每一位表示是否有该优先级的任务处于就绪态,对于cortex-m3有32位,如
    // 0000 0000 0000 0000 0000 0000 0000 0001 表示第0级有就绪态的任务
    #define taskRECORD_READY_PRIORITY( uxPriority )    portRECORD_READY_PRIORITY( uxPriority, uxTopReadyPriority )

    /*-----------------------------------------------------------*/

    #define taskSELECT_HIGHEST_PRIORITY_TASK()                                                        \
    {                                                                                                \
    UBaseType_t uxTopPriority;                                                                        \
                                                                                                    \
        // 对于Cortex-m3 其会调用CLZ汇编指令(计算变量从高位开始连续0的个数),快速获取当前任务中的最高优先级
        portGET_HIGHEST_PRIORITY( uxTopPriority, uxTopReadyPriority );                                \
        configASSERT( listCURRENT_LIST_LENGTH( &( pxReadyTasksLists[ uxTopPriority ] ) ) > 0 );        \
        listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, &( pxReadyTasksLists[ uxTopPriority ] ) );        \
    } /* taskSELECT_HIGHEST_PRIORITY_TASK() */

    /*-----------------------------------------------------------*/

    #define taskRESET_READY_PRIORITY( uxPriority )                                                        \
    {                                                                                                    \
        if( listCURRENT_LIST_LENGTH( &( pxReadyTasksLists[ ( uxPriority ) ] ) ) == ( UBaseType_t ) 0 )    \
        {                                                                                                \
            portRESET_READY_PRIORITY( ( uxPriority ), ( uxTopReadyPriority ) );                            \
        }                                                                                                \
    }

其使用一个4字节整型变量来表示32个优先级下是否有就绪的任务(在优化版本中,优先级是一定的,最高为31),若有就绪的任务,该位被置为1,没有则置为0。portRECORD_READY_PRIORITY(),portRESET_READY_PRIORITY(),portGET_HIGHEST_PRIORITY()这几个宏定义函数内容如下

#define portRECORD_READY_PRIORITY( uxPriority, uxReadyPriorities ) ( uxReadyPriorities ) |= ( 1UL << ( uxPriority ) )

    #define portRESET_READY_PRIORITY( uxPriority, uxReadyPriorities ) ( uxReadyPriorities ) &= ~( 1UL << ( uxPriority ) )

    #define portGET_HIGHEST_PRIORITY( uxTopPriority, uxReadyPriorities ) uxTopPriority = ( 31UL - ( uint32_t ) __clz( ( uxReadyPriorities ) ) )

本质上就是对对应位的置位,复位处理,使用CLZ指令可以获得从高位开始连续0的个数,用31减去这个数,便可以获得就绪任务的最高优先级,其余处理和通用方法一样。

进入任务切换的方式

FreeRTOS进入任务切换的方式有以下两种

  1. 在xPortSysTickHandler()中断中进入,也就是在系统systick增加时,根据情况进入任务切换。

  2. 手动调用portYIELD_WITHIN_API()或taskYIELD_IF_USING_PREEMPTION()(在启用抢占模式的情况下其和portYIELD_WITHIN_API一样,非抢占模式下,其没有任何作用)直接进行一次任务切换。

由于任务切换过程是和硬件平台相关的,这里以cortex-m3内核为例进行分析,其它内核可以类比cortex-m3内核的切换方式。 cortex-m3平台下xPortSysTickHandler()函数如下,

void xPortSysTickHandler( void )
    {
        vPortRaiseBASEPRI();
        {
            // 这里并不是每次进入系统滴答中断都会进行上下文切换,只有有任务从阻塞状态退出或者在时间片轮询模式中有相同的优先级的任务,才会进行上下文切换。
            if( TxTaskIncrementTick() != pdFALSE )
            {
                // 触发一次PendSV异常,进入PendSV中断。
                portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
            }
        }
        vPortClearBASEPRIFromISR();
    }

xTaskIncrementTick()函数的主要功能是在在任务切换器工作时修改systick的值,并根据systick值的变化判断是否需要进行一次任务切换动作;在任务切换器被挂起时,其会记录任务切换器挂起期间漏掉的systick数,一旦任务切换器恢复运行,任务切换器会补上漏掉的systick和相应的任务切换动作在任务切换器工作时,当以下两种情况发生时,xTaskIncrementTick()将返回pdTRUE,以触发一次PendSV中断,以进行任务切换动作

  1. 当前时刻有任务需要退出阻塞状态

  2. 启用时间片模式,当前优先级下有多个任务,需要共享使用权。

portYIELD_WITHIN_API()等接口函数,是直接触发PendSV中断的,每调用一次便立刻进行一次任务切换。这个函数一般用在删除当前任务,补全systick等特殊动作时调用。

PendSV中断任务切换

cortex-m3内核有两个堆栈指针,分别是MSP(Main Stack Pointer主栈指针)和PSP(Process Stack Pointer进程栈指针),这两个指针共用一个引用R13(SP),引用的是当前正在使用的栈指针。在任何时刻只能使用其中的一个作为栈指针,复位后的默认情况下使用的是MSP栈指针(裸机程序仅使用一个栈指针即可),在以下一些情况下使用的栈指针可以发生改变

  1. 修改CONTROL寄存器的高一位CONTROL[1],0表示选择MSP,1表示使用PSP

  2. 进入中断后,CONTROL[1]自动置为1,只能选择使用MSP

  3. 异常返回时,LR寄存器用法发生改变,其会自动存储特殊值EXC_RETURN,EXC_RETURN表示了运行模式的一些信息,其中其第二位表示返回后使用的指针,0表示MSP,1表示PSP。

一般而言,MSP会被用于RTOS的内核以及异常处理,而用户的任务会使用PSP,RTOS中MSP和PSP的使用方式如下图所示。

在这里插入图片描述
所谓任务切换也就相当于修改PSP指针的值,使其指向具有最高优先级任务正确的栈指针位置并且恢复处理器中的相应寄存器的值。

堆栈这两个词常被一起使用,但本质上这是两个不同的东西。简单而言,堆是由程序编写者自由分配的内存区域由编写者自己管理,如使用malloc和free来进行分配和释放,而栈是由系统自动分配和管理的,不需要编写者干预,程序中函数的局部变量,参数,调用上下文等,都是存放在栈上的,当函数运行结束,这些变量栈上占用的空间会被自动释放掉。

栈和堆的大小在编写、编译时都是无法确定的,只有在程序实际运行时才会真正的分配。例如在STM32中,如果启动文件下设置了栈的大小为1k,而编写的函数中有一大小为2k的数组,远大于分配的栈大小,编译时编译器并不对这一情况报错,在未执行该函数时程序也是正常运行的,但一旦执行该函数,程序必然会出现一些莫名其妙的错误。因此编写程序时,要注意使用的栈大小,合理分配栈大小,避免栈溢出。堆的大小同样要注意,避免内存不够用的情况。堆栈的分配过小会导致程序运行错误,而分配的值过大则会导致内存资源的浪费。

C语言程序中除了堆区、栈区外还有数据区和代码区,C语言中数据的存储结构如下图所示

在这里插入图片描述

任务切换的动作都是在PendSV中断中完成的,由以上的分析,可以判断,PendSV中断中需要完成三个动作,将当前任务的任务堆栈保存,将待切换任务的任务堆栈恢复,将PSP指向正确的位置。至此可以清晰的理解以下的汇编代码

__asm void xPortPendSVHandler( void )
    {
        extern uxCriticalNesting;
        extern pxCurrentTCB;
        extern vTaskSwitchContext;

        /*DMB
        数据存储器隔离。DMB 指令保证仅当所有在它前面的存储器访问操作
        都执行完毕后,才提交(commit)在它后面的存储器访问操作。

        DSB
        数据同步隔离。比 DMB 严格: 仅当所有在它前面的存储器访问操作
        都执行完毕后,才执行在它后面的指令(亦即任何指令都要等待存储器访 问操作)

        ISB
        指令同步隔离。最严格:它会清洗流水线,以保证所有它前面的指令都执
        行完毕之后,才执行它后面的指令。*/

        /* step1 保存当前任务现场 */
        /* =================================================================*/

        // 字节对齐
        PRESERVE8

        // PendSV中断产生时,硬件自动将xPSR,PC(R15),LR(R14),R12,R3-R0使用PSP压入任务堆栈中,进入中断后硬件会强制使用MSP指针,此时LR(R14)的值将会被自动被更新为特殊的EXC_RETURN.

        mrs r0, psp                    // 保存进程堆栈指针到R0
        isb                        

        ldr    r3, =pxCurrentTCB        // 读取当前TCB块的地址到R3
        ldr    r2, [r3]                // 将当前任务栈顶地址放到R2中,这是为什么强调栈顶指针一定得是TCB块的第一个成员的原因

        stmdb r0!, {r4-r11}            // 将R4到R11通用寄存器的值压入栈保存
        str r0, [r2]                // 将R0的值写入以R2为地址的内存中,也就是保存当前的栈顶地址到TCB的第一个成员,也就是栈顶指针

        stmdb sp!, {r3, r14}        // 将R3,R14临时压栈,这里的SP其实使用的是MSP,这里进行压栈保护的原因是bl指令会自动更改R14值用于返回

        // 屏蔽configMAX_SYSCALL_INTERRUPT_PRIORITY以下优先级的中断
        mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
        msr basepri, r0

        /* step2 恢复待切换任务的现场 */
        /* =================================================================*/

        dsb
        isb
        bl vTaskSwitchContext        // 这里调用vTaskSwitchContext函数来获取下一个要执行任务控制块

        // 取消中断屏蔽
        mov r0, #0
        msr basepri, r0

        ldmia sp!, {r3, r14}        // 将R3,R14出栈,这里R3相当于是pxCurrentTCB内存的值,所以此时R3值已经更新为下一个要执行的任务TCB地址了

        ldr r1, [r3]                
        ldr r0, [r1]                // 把新任务的栈顶指针放到R0里
        ldmia r0!, {r4-r11}            // 将新任务的R4-R11出栈

        /* step3 更改PSP指针值 */
        /* =================================================================*/
        msr psp, r0                    // 将新的栈顶地址放入到进程堆栈指针PSP
        isb
        bx r14    // 异常发生时,R14中保存异常返回标志,包括返回后进入线程模式还是处理器模式、使用PSP堆栈指针还是MSP堆栈指针,当调用 bx r14指令后,硬件会知道要从异常返回,然后出栈,这个时候堆栈指针PSP已经指向了新任务堆栈的正确位置,当新任务的运行地址被出栈到PC寄存器后,新的任务也会被执行。
        nop
    }

以上便是任务切换的所有过程,但这里其实还存在着两个问题

  1. 处理器复位后默认使用的是MSP指针,它是怎么被切换成PSP的?

  2. 新任务被进行第一次调度时,它的私有栈的值怎么处理?

对于第一个问题,任务切换器在启动时会调用prvStartFirstTask()函数,这个函数也是一段汇编代码,它的主要工作就是复位MSP,开中断和异常,并且触发一次SVC中断,进行第一次任务的加载,其内容如下

__asm void prvStartFirstTask( void )
    {
        PRESERVE8

        /* 定位主站指针初始位置 */
        ldr r0, =0xE000ED08     //向量表偏移量寄存器的起始地址存储着MSP的初始值
        ldr r0, [r0]
        ldr r0, [r0]

        /* 复位MSP */
        msr msp, r0
        /* 使能全局中断和异常 */
        cpsie i
        cpsie f
        dsb
        isb
        /* 触发SVC中断. */
        svc 0
        nop
        nop
    }

再来看SVC异常服务函数里的代码

__asm void vPortSVCHandler( void )
    {
        PRESERVE8

        /* 恢复任务现场 */
        ldr    r3, =pxCurrentTCB    
        ldr r1, [r3]            
        ldr r0, [r1]            
        ldmia r0!, {r4-r11}        

        /* 修改PSP */
        msr psp, r0                
        isb
        mov r0, #0
        msr    basepri, r0

        /* 进入线程模式,使用PSP指针 */
        orr r14, #0xd            // 1101 每一位表示:进入线程模式,使用PSP,必须为0,thumb状态
        bx r14
    }

看懂PendSV中的代码再看这段代码,就比较简单了,最为重要的是最后一步,其修改了r14(EXC_RETURN)的值,正是修改该值使得处理器在退出中断后运行任务函数时进入线程模式并使用PSP栈指针。

对于第二个问题,FreeRTOS在添加新任务时,会调用pxPortInitialiseStack()函数来按处理器规则填充其私有栈的值,将任务的私有栈"伪装"成已经被调度过一次的样子

StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxCode, void *pvParameters )
    {
        // 这里空出一个存储地址是为了符合MCU进出中断的方式
        pxTopOfStack--; 
        // 栈中寄存器xPSR被初始为0x01000000,其中bit24被置1,表示使用Thumb指令
        *pxTopOfStack = portINITIAL_XPSR;    
        pxTopOfStack--;
        // 这是将任务函数地址压入栈中程序PC(R15),当该第一次切换任务时,硬件的PC指针将指向该函数,也就是会从头执行这个任务。
        // & portSTART_ADDRESS_MASK是保证地址对齐
        *pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK;    /* PC */
        pxTopOfStack--;
        // 正常任务是死循环,不会使用LR进行返回,这里赋为错误处理函数地址,出错时会进入该函数。
        *pxTopOfStack = ( StackType_t ) prvTaskExitError;    /* LR */

        // 跳过R12,R3,R2,R1不用初始化
        pxTopOfStack -= 5;    /* R12, R3, R2 and R1\. */
        // 初始化参数地址
        *pxTopOfStack = ( StackType_t ) pvParameters;    /* R0 */
        pxTopOfStack -= 8;    /* R11, R10, R9, R8, R7, R6, R5 and R4\. */

        return pxTopOfStack;
    }

以上便是FreeRTOS在crotex-m3平台上任务切换的全部过程。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

FreeRTOS解析:任务切换(TASK-3) 的相关文章