FreeRTOS_任务切换

目录

1. RendSV 异常

2. FreeRTOS 任务切换场合

2.1 执行系统调用

2.2 系统滴答定时器(SysTick)中断

3. PendSV 中断服务函数

4. 查找下一个要运行的任务

4.1 通用方法

4.2 硬件方法

5. FreeRTOS 的时间片调度

6. 时间片调度实验

6.1 实验程序


        RTOS 系统的核心是任务管理,而任务管理的核心是任务切换,任务切换决定了任务的执行顺序,任务切换效率的高低也决定了一款系统的性能,尤其是对于实时操作系统;如果我们想要深入的了解 FreeRTOS 的运行过程,那么任务切换是必须要掌握的。

1. RendSV 异常

        PendSV(可挂起的系统调用)异常对 OS 操作非常重要,其优先级可以通过编程设置。可以通过将中断控制和状态寄存器 ICSR 的 bit28,也就是 PendSV 的挂起位置 1 来触发 PendSV 中断。与 SVC 异常不同,PendSV 是不精确的,因此它的挂起状态可在更高优先级异常处理内设置,且会在高优先级处理完成后执行。

这里首先介绍几个定义:

SVC:

        在 FreeRTOS 中,SVC 代表 Supervisor Call,它是一种特殊的指令或中断,用于实现操作系统的服务调用。当应用程序需要操作系统提供的特权功能时,可以通过 SVC 指令触发一个软中断,并将参数传递给操作系统内核。操作系统接收到 SVC 中断后,会根据参数执行相应的服务,并返回结果给应用程序。

        SVC 指令通常用于实现操作系统的系统调用和任务切换等功能。通过 SVC,应用程序可以请求操作系统进行资源分配、任务管理、中断处理等操作,从而实现多任务协作和资源管理。在 FreeRTOS 中,SVC 指令的实现可以是硬件相关的,在不同的平台上可能存在差异。

PendSV:

        在 FreeRTOS 中,PendSV(Pending Supervisor Call)是一个与任务调度相关的中断。它是由 ARM Cortex-M 处理器架构提供的一种中断请求。PendSV 中断用于实现任务切换和任务调度,通常与 SysTick 定时器一起使用。当 PendSV 中断触发时,它会被操作系统内核用于执行任务切换和调度的相关操作。在 FreeRTOS 中,当一个任务的时间片用完或发生更高优先级的任务就绪时,操作系统会通过 PendSV 中断来触发任务切换。PendSV 中断的优先级要设置为最低优先级,这样可以确保 PendSV 中断始终处于最低优先级,不会被其他中断打断。

        当 PendSV 中断发生时,操作系统会保存当前任务的上下文信息,包括任务的寄存器值、堆栈指针等,然后加载下一个要执行的任务的上下文信息,切换到新的任务继续执行。这样实现了任务的切换和调度。

        需要注意的是,PendSV 中断的执行是由操作系统内核负责的,应用程序开发者一般无需直接操作 PendSV 中断,而是使用 FreeRTOS 提供的任务管理接口和调度器来进行任务的创建、删除和切换。

系统调用:

        在 FreeRTOS 中,系统调用是通过特定的函数接口来访问操作系统提供的功能和服务。通过系统调用,应用程序可以请求操作系统执行特定的操作,如任务管理、资源管理、时间管理等。系统调用函数是操作系统内核中提供的 API,用于与应用程序进行交互。

FreeRTOS 中常见的系统调用包括但不限于以下几类:

  1. 任务管理:通过系统调用函数可以创建任务、删除任务、挂起任务、恢复任务、设置任务优先级等。

  2. 资源管理:系统调用函数可以申请和释放内存、信号量、消息队列、事件标志组等资源,进行资源的分配和管理。

  3. 中断管理:通过系统调用函数可以使能和禁用中断,配置中断优先级,注册中断处理程序等。

  4. 时间管理:系统调用函数可以获取当前时钟值、延时指定时间、延时等待事件等。

具体的系统调用接口和函数名称可以参考 FreeRTOS 的官方文档或相关的编程指南。

        利用该特性,若将 PendSV 设置为最低的异常优先级,可以让 PendSV 异常处理在所有其他中断处理完成后执行,这对于上下文切换非常有用,也是各种 OS 设计中的关键。将 PendSV 设置为最低优先级以后,PendSV 是不会被打断的。

        在具有嵌入式 OS 的典型系统中,处理时间被划分为了多个时间片。倘若系统只有两个任务,则这两个任务会交替执行,如下图所示:

        上述图片的意思就是:线程任务 A ,在系统滴答定时器中断 SysTick 中进行上下文切换,每次都会切换到另外一个任务;因为上图只有两个任务,所以这两个任务进行循环切换。

上下文切换被触发的场合可以是:

  •         执行一个系统调用
  •         系统滴答定时器(SysTick)中断

        在 OS 中,任务调度器决定是否应该执行上下文切换,正如上图所示:任务切换都是由 SysTick 中断中执行的,每次它都会决定切换到一个不同的任务中。

SysTick 中断:

        在 FreeRTOS 中,SysTick 是一个系统定时器,它是在硬件层面上提供的一个计时器。SysTick 定时器通常用于操作系统的时间管理和任务调度。

SysTick 定时器在 FreeRTOS 中常用于两个方面:

  1. 任务调度:SysTick 定时器可以被配置为以固定时间间隔中断的方式运行。每当 SysTick 定时器中断发生时,操作系统内核会进行调度,检查任务优先级、任务状态和时间片等信息,从而决定下一个要执行的任务。这样可以实现多任务的时间共享和调度。

  2. 操作系统节拍:SysTick 定时器还可以被配置为以一定频率产生中断,作为操作系统的节拍信号。这个节拍信号在操作系统的时间管理中起关键作用,用于计算任务的延时时间、超时判断和时间片轮转等。

在 FreeRTOS 中,通过配置 SysTick 定时器的初始化参数,可以设置定时器的中断频率和工作模式。

        若中断请求(IRQ)在 SysTick 异常前产生,则 SysTick 异常可能会抢占 IRQ 的处理,在这种情况下,OS 不应该执行上下文切换,否则中断请求 IRQ 就会被延迟(之所以被延迟是很好理解的,如果中断请求 IRQ 在 SysTick 异常前产生,那么 SysTick 异常会抢占 IRQ 的处理,那么线程无法进入 IRQ 中断,会直接进入 SysTick 异常,这样中断请求 IRQ 会被延迟),而且在真实系统中延迟时间还往往不可预知——任何有一丁点实时要求的系统都决不能容忍这种事。

        对于 Cortex-M3 和 Cortex-M4 处理器来说,当存在活跃的异常服务时,设计默认不允许返回到线程模式,若存在活跃中断服务,且 OS 试图返回到线程模式,则将触发用法 fault,如下图所示:

意思是:

        在 Cortex-M3 和 Cortex-M4 处理器中,当存在活跃的异常服务(包括中断服务和其他异常处理)时,按照设计,默认情况下不允许返回到线程模式。

        活跃的异常服务是指当前正在执行的中断服务例程或其他异常处理例程。这些例程通常会使用堆栈储存相关的寄存器值和局部变量。当处理器处于异常服务状态时,不允许在异常服务例程执行完毕后返回到线程模式。

        当操作系统(OS)试图将处理器从异常服务状态切换回线程模式时,如果仍存在活跃的中断服务,即还未完成或返回到线程模式,则会触发用法错误(Usage Fault)。用法错误是一种处理器异常,指示了不合法的指令或操作。

        这种设计决策是为了确保异常服务的完整性和可靠性。因为线程模式可能对栈帧有不同的管理需求,与异常服务模式存在区别,因此当存在活跃中断服务时,不允许直接返回到线程模式。在遇到这种情况时,需要确保中断服务完全执行完毕或返回到线程模式后才能进行任务的切换和调度。此外,开发者还可以通过使用特定的操作系统机制(如临界区保护)来保证中断服务和线程模式之间的同步和正确性。

        在一些 OS 设计中,要解决这个问题,可以在运行中断服务时不执行上下文切换,此时可以检查栈帧中的压栈 xPSR 或 NVIC 中的中断活跃状态寄存器。不过,系统的性能可能会受到影响,特别是当中断源在 SysTick 中断前后持续产生请求时,这样上下文切换可能就没有执行的机会了。

可以检查栈帧中的压栈 xPSR 或 NVIC 中的中断活跃状态寄存器:

        在一些操作系统的设计中,为了解决在运行中断服务时可能发生上下文切换导致的性能开销,可以通过检查栈帧中的压栈 xPSR(特殊程序状态寄存器)或 NVIC(Nested Vectored Interrupt Controller,嵌套向量中断控制器)中的中断活动状态来确定是否需要进行上下文切换。

        这种方式是通过检查中断活动状态来决定是否启用或禁用上下文切换。当一个中断服务例程开始执行时,操作系统会通过查看栈帧中的 xPSR 或 NVIC 中的中断活动状态,判断当前是否处于中断嵌套的情况下。如果不处于中断嵌套状态,即只有当前一个中断被触发,那么操作系统可以决定不执行上下文切换操作,直接在当前任务的上下文中执行中断服务例程。这样做可以减少上下文切换的开销,提升系统的运行效率。

        然而,这种方式需要确保在不执行上下文切换的情况下,中断服务例程的执行时间较短,不会长时间占用 CPU 资源,否则可能会影响其他任务的响应和系统的稳定性。因此,在设计中要权衡考虑中断服务例程的执行时间和对系统性能的影响。

        为了解决这一问题,PendSV 异常将上下文切换请求延迟到所有其他 IRQ 处理都已经完成后,此时需要将 PendSV 设置为最低优先级。若 OS 需要执行上下文切换,他会设置 PendSV 的挂起状态,并在 PendSV 异常内执行上下文切换。

        (1)任务 A 呼叫 SVC 来请求任务切换(例如,等待某些工作完成)

        (2)OS 接收到请求,做好上下文切换的意思,并且 Pend 一个 PendSV 异常

        (3)当 CPU 退出 SVC 后,它立即进入 PendSV ,从而执行上下文切换

        (4)当 PendSV 执行完毕后,将返回到任务 B,同时进入线程模式

        (5)发生一个中断,并且中断服务程序 ISR 开始执行

        (6)在 ISR 的执行过程中,发生 SysTick 异常,并且抢占了该 ISR

        (7)OS 执行必要的操作,然后 Pend 起 PendSV 异常以做好上下文切换的准备

        (8)当 SysTick 退出后,回到先前被抢占的 ISR 中,ISR 继续执行

        (9)ISR 执行完毕并退出后,PendSV 服务例程开始执行,并且在里面执行上下文切换

        (10)当 PendSV 执行完毕后,回到任务 A,同时系统再次进入线程模式。

注意牢记:FreeRTOS 系统的任务切换最终都是在 PendSV 中断服务函数中完成的,UCOS 也是在 PendSV 中断中完成任务切换的。

2. FreeRTOS 任务切换场合

        在上一部分学习 PendSV 异常中断的时候提到了上下文(任务)切换被触发的场合

  •         可以执行一个系统调用
  •         系统滴答定时器(SysTick)中断

2.1 执行系统调用

系统调用:

        在 FreeRTOS 中,系统调用是通过特定的函数接口来访问操作系统提供的功能和服务。通过系统调用,应用程序可以请求操作系统执行特定的操作,如任务管理、资源管理、时间管理等。系统调用函数是操作系统内核中提供的 API,用于与应用程序进行交互。

FreeRTOS 中常见的系统调用包括但不限于以下几类:

  1. 任务管理:通过系统调用函数可以创建任务、删除任务、挂起任务、恢复任务、设置任务优先级等。

  2. 资源管理:系统调用函数可以申请和释放内存、信号量、消息队列、事件标志组等资源,进行资源的分配和管理。

  3. 中断管理:通过系统调用函数可以使能和禁用中断,配置中断优先级,注册中断处理程序等。

  4. 时间管理:系统调用函数可以获取当前时钟值、延时指定时间、延时等待事件等。

具体的系统调用接口和函数名称可以参考 FreeRTOS 的官方文档或相关的编程指南。

        执行系统调用就是执行 FreeRTOS 系统提供的相关 API 函数,比如任务切换函数 taskYIELD(),FreeRTOS 有些 API 函数也会调用函数 taskYIELD(),这些 API 函数都会导致任务切换,这些 API 函数和任务切换函数 taskYIELD() 都统称为系统调用。函数 taskYIELD() 其实就是个宏,在文件 task.h 中有如下定义:

#define taskYIELD() portYIELD() 

        函数 portYIELD() 也是个宏,在文件 portmacro.h 中有如下定义:

#define portYIELD() \ 
{ \ 
 portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \ (1) 
 \ 
 __dsb( portSY_FULL_READ_WRITE ); \ 
 __isb( portSY_FULL_READ_WRITE ); \ 
}

        (1)、通过向中断控制和状态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中断。这样就可以在 PendSV 中断服务函数中进行任务切换了。

        中断级的任务切换函数为 portYIELD_FROM_ISR(),定义如下:

#define portEND_SWITCHING_ISR( xSwitchRequired ) \ 
if( xSwitchRequired != pdFALSE ) portYIELD() 
#define portYIELD_FROM_ISR( x ) portEND_SWITCHING_ISR( x ) 

        可以看出 portYIELD_FROM_ISR() 最终也是通过调用函数 portYIELD() 来完成任务切换的。

2.2 系统滴答定时器(SysTick)中断

        FreeRTOS 中滴答定时器(SysTick)中断服务函数中也会进行任务切换,滴答定时器中断服务函数如下:

void SysTick_Handler(void) 
{ 
    if(xTaskGetSchedulerState()!=taskSCHEDULER_NOT_STARTED)//系统已经运行 
    { 
        xPortSysTickHandler(); 
    } 
}

        在滴答定时器中断服务函数中调用了 FreeRTOS 的 API 函数 xPortSysTickHandler(),此函数源码如下:

void xPortSysTickHandler( void ) 
{ 
    vPortRaiseBASEPRI();                                         (1) 
    { 
        if( xTaskIncrementTick() != pdFALSE ) //增加时钟计数器 xTickCount 的值 
        { 
            portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;          (2) 
        } 
    } 
    vPortClearBASEPRIFromISR();                                  (3) 
}

(1)关闭中断

(2)通过向中断控制和状态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中断。这样就可以在 PendSV 中断服务函数中进行任务切换了。

(3)打开中断

3. PendSV 中断服务函数

        在前面我们已经学习了 FreeRTOS 任务切换的具体过程是在 PendSV 中断服务函数中完成的,本节我们就来学习 PendSV 的中断服务函数,学习一下任务切换的过程究竟是怎么进行的。PendSV 中断服务函数本应该为 PendSV_Handler(),但是 FreeRTOS 使用 #define 重定义了,如下:

#define xPortPendSVHandler  PendSV_Handler

        函数 xPortPendSVHandler() 源码如下:

__asm void xPortPendSVHandler( void ) 
{ 
    extern uxCriticalNesting; 
    extern pxCurrentTCB; 
    extern vTaskSwitchContext; 
 
    PRESERVE8 
 
    mrs r0, psp                     (1) 读取进程栈指针,保存在寄存器 R0 里面
    isb 
 
    ldr r3, =pxCurrentTCB             (2) 
    ldr r2, [r3]                     (3)  获取当前任务的任务控制块,并将任务控制块的地址保存在寄存器 R2 里面
 
    tst r14, #0x10                 (4) 
    it eq                             (5) 判断任务是否使用了 FPU,如果任务使用了 FPU 的话在进行任务切换的时候就需要将 FPU 寄存器 s16~s31 手动保存到任务堆栈中,其中 s0~s15 和 FPSCR 是自动保存的
    vstmdbeq r0!, {s16-s31}         (6) 保存 s16~s31 这16个 FPU 寄存器
    stmdb r0!, {r4-r11, r14}         (7) 保存 R4~R11 和 R14 这几个寄存器的值
    str r0, [r2]                     (8) 将寄存器 R0 的值写入到寄存器 R2 所保存的地址中去,也就是将新的栈顶保存在任务控制块的第一个字段中。此时的寄存器 R0 保存着最新的堆栈栈顶指针值,所以要将这个最新的栈顶指针写入到当前任务的任务控制块第一个字段,而经过(2)和(3)已经获取到了任务控制块,并将任务控制块的首地址写到了寄存器 R2 中。
 
    stmdb sp!, {r3}                 (9) 将寄存器 R3 的值临时压栈,寄存器 R3 中保存了当前任务的任务控制块,而接下来要调用函数 vTaskSwitchContext(),为了防止 R3 的值被改写,所以这里临时将 R3 的值先压栈
    mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY     (10) 
    msr basepri, r0                 (11) 关闭中断,进入临界区
    dsb 
    isb 
    bl vTaskSwitchContext             (12) 调用函数 vTaskSwitchContext(),此函数用来获取下一个要运行的任务,并将 pxCurrentTCB 更新为这个要运行的任务
    mov r0, #0                     (13) 
    msr basepri, r0                 (14) 打开中断,退出临界区
    ldmia sp!, {r3}                 (15) 刚刚保存的寄存器 R3 的值出栈,恢复寄存器 R3 的值。注意,经过(12)步,此时 pxCurrentTCB 的值已经改变了,所以读取 R3 所保存的地址处的数据就会发现其值改变了,成为了下一个要运行的任务的任务控制块。
 
    ldr r1, [r3]                     (16) 
    ldr r0, [r1]                     (17) 获取新的要运行的任务的任务堆栈栈顶,并将栈顶保存在寄存器 R0 中
    ldmia r0!, {r4-r11, r14}         (18) R4~R11,R14出栈,也就是即将要运行的任务的现场
 
    tst r14, #0x10                     (19) 
    it eq                             (20) 
    vldmiaeq r0!, {s16-s31}         (21) 判断即将运行的任务是否有使用到 FPU ,如果有的话还需要手工恢复 FPU 的 s16~s31 寄存器
 
    msr psp, r0                         (22) 更新进程栈指针 PSP 的值
    isb 
    bx r14                            (23) 执行此行代码以后硬件自动恢复寄存器 R0~R3、R12、LR、PC 和 xPSR 的值,确定异常返回以后应该进入处理器模式还是进程模式,使用主栈指针(MSP)还是进程栈指针(PSP)。很明显这里会进入进程模式,并且使用进程栈指针(PSP),寄存器 PC 值会被恢复为即将运行的
任务的任务函数,新的任务开始运行!至此,任务切换成功。
} 

4. 查找下一个要运行的任务

        在 PendSV 中断服务程序中有调用函数 vTaskSwitchContext() 来获取下一个要运行的任务,也就是查找已经就绪了的优先级最高的任务,缩减后的函数源码如下:

void vTaskSwitchContext( void ) 
{ 
    if( uxSchedulerSuspended != ( UBaseType_t ) pdFALSE )         (1) 
    { 
        xYieldPending = pdTRUE; 
    } 
    else 
    { 
        xYieldPending = pdFALSE; 
        traceTASK_SWITCHED_OUT(); 
        taskCHECK_FOR_STACK_OVERFLOW(); 
 
        taskSELECT_HIGHEST_PRIORITY_TASK();                     (2) 
 
        traceTASK_SWITCHED_IN(); 
    } 
}

(1)判断调度器是否被挂起,如果调度器被挂起,那就不能进行任务切换

(2)调用函数 taskSELECT_GIGHEST_PRIORITY_TASK() 获取下一个要运行的任务。(也就是任务优先级最高的任务),taskSELECT_HIGHEST_PRIORITY_TASK() 本质上是一个宏,在 task.c 中有定义。

        FreeRTOS 中查找下一个运行的任务有两种方法:一个是通用的方法,另外一个是使用硬件的方法,至于选择哪种方法通过宏 configUSE_PORT_OPTIMISED_TASK_SELECTION 来决定的,当这个宏为 1 的时候使用硬件的方法,否则就使用通用的方法

4.1 通用方法

        通用方法:顾名思义就是所有处理器都可以使用的方法。

#define taskSELECT_HIGHEST_PRIORITY_TASK() 
{ 
    UBaseType_t uxTopPriority = uxTopReadyPriority; 
    while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) )     (1) 
    { 
        configASSERT( uxTopPriority ); 
        --uxTopPriority;
    } 
    listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB,                              (2) 
                                &( pxReadyTasksLists[ uxTopPriority ] ) );  
    uxTopReadyPriority = uxTopPriority; 
}

        (1)pxReadyTasksLists[] 为就绪任务列表数组,一个任务一个列表,同优先级的就绪任务都挂到相对应的列表中。uxTopReadyPriority 代表处于就绪态的最高优先级值,每次创建任务的时候都会判断新任务的优先级是否大于 uxTopReadyPriority,如果大于的话就将这个新任务的优先级赋值给变量 uxTopReadyPriority。函数 prvAddTaskToReadyList() 也会修改这个值,也就是说将某个任务添加到就绪列表中的时候都会用 uxTopReadyPriority 来记录就绪列表中的最高优先级(也就是说这个变量始终记录着列表中的最高优先级),这样直接去看每个列表的最高优先级即可,从这个最高优先级开始判断,看看哪个列表不为空就说明哪个优先级有就绪的任务(因为一个列表代表着一个优先级,一个列表中所有任务的优先级都是一样的)。函数 listLIST_IS_EMPTY() 用于判断某个列表是否为空,uxTopPriority 用来记录这个有就绪任务的优先级。

        (2)程序运行到这里,表示已经找到了有就绪任务的优先级了,接下来就是从对应的列表中找出下一个要运行的任务,查找方法就是使用函数 listGET_OWNER_OF_NEXT_ENTRY() 来获取列表中的下一个列表项,然后将获取到的列表项所对应的任务控制块赋值给 pxCurrentTCB,这样我们就确定了下一个要运行的任务了。

        通过观察上述程序可以发现,通用方法的程序完全是通过 C 语言来实现的,所以适用于不同的芯片和平台,而且对于任务数量没有限制,但是效率肯定相对于硬件的方法要低很多。

4.2 硬件方法

        硬件方法就是使用处理器自带的硬件指令来实现的,比如 Cortex-M 处理器就带有的计算前导零的个数:CLZ,函数如下:

计算前导零的个数:

        前导零的个数就是从最高位(bit31)到第一个为 1 的 bit,中间 0 的个数,如下例子

        二进制 1000 0000 0000 0000 的前导零的个数就为 0,因为 1 是第 31 位,最高位之前没有 0

        二进制 0000 1001 1111 0001 的前导零的个数就是4,因为 1 是第 27 位,27 位之前有 4 个零

#define taskSELECT_HIGHEST_PRIORITY_TASK() 
{ 
    UBaseType_t uxTopPriority; 
    portGET_HIGHEST_PRIORITY( uxTopPriority, uxTopReadyPriority );         (1) 
    configASSERT( listCURRENT_LIST_LENGTH( &  
                ( pxReadyTasksLists[ uxTopPriority ] ) )> 0 );
    listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB,                             (2) 
                                &( pxReadyTasksLists[ uxTopPriority ] ) ); 
}

        (1)、通过函数 portGET_HIGHEST_PRIORITY() 获取处于就绪态的最高优先级,portGET_HIGHEST_PRIORITY 本质上是一个宏,定义如下:

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

        使用硬件的方法的时候 uxTopReadyPriority 就不代表处于就绪态的最高优先级了,而是使用每个bit 位代表一个优先级,bit 0 代表优先级 0,bit 31 代表优先级 31,当某个优先级有就绪任务的话就将其对应的 bit 置 1。从这里可以看出,如果使用硬件的方法的话最多只能有 32 个优先级,对应于32位,__clz(uxReadyPriorities)就是计算 uxReadyPriorities 的前导零个数,前导零个数就是指从 最高位开始(bit31)到第一个为 1 的 bit,其间 0 的个数。

        得到 uxTopReadyPriority 前导零的个数的话以后就用 31 减去这个前导零的个数得到的就是处于就绪态的最高优先级了,比如优先级 30 为此时的处于就绪态的最高优先级,30 的前导零个数为 1,那么 31-1=30,得到处于就绪态的最高优先级为 30。

        (2)、已经找到了处于就绪态的最高优先级了,接下来就是从对应的列表中找出下一个要运 行的任务,查找方法就是使用函数 listGET_OWNER_OF_NEXT_ENTRY()来获取列表中的下一 个列表项,然后将获取到的列表项所对应的任务控制块赋值给 pxCurrentTCB,这样我们就确定 了下一个要运行的任务了。

        可以看出硬件方法借助一个指令就可以快速的获取处于就绪态的最高优先级,但是会限制 任务的优先级数,比如 STM32 只能有 32 个优先级,不过 32 个优先级已经完全够用了。要知道FreeRTOS 是支持时间片的,每个优先级可以支持无限多个任务。

5. FreeRTOS 的时间片调度

        在 FreeRTOS 中允许一个任务运行一个时间片(一个时钟节拍的长度)后让出 CPU 的使用权,至于下一个要运行哪个任务,我们在上一部分已经学习过了,通过两种方法去查找下一个要运行的任务。

时间片:

        在FreeRTOS(一个嵌入式实时操作系统)中,时间片(time slice)是任务调度的一个概念,用于决定每个任务在调度器中运行的时间。

        在多任务环境下,操作系统需要公平地分配处理器时间给各个任务,以便它们能够交替执行。时间片就是用来划分处理器时间的单位。当一个任务被调度并获得运行的机会时,它将被分配一个时间片,该时间片决定了该任务能够连续运行的时间。

        当一个任务的时间片用完后,操作系统会将处理器的控制权转移到下一个就绪的任务上,以保证每个任务都有公平的执行机会。这种调度方式被称为抢占式调度(preemptive scheduling),因为操作系统可以在任务执行期间抢占处理器的控制权。

        时间片的长度通常是固定的,但在FreeRTOS中可以通过调整系统配置来改变时间片的大小。较小的时间片可以提高系统的响应速度,但也会增加任务切换的频率,从而引入额外的开销。较大的时间片则可以减少任务切换的开销,但可能导致任务响应速度较慢。

        总而言之,时间片在FreeRTOS中用于划分处理器时间,以实现多任务调度和抢占式调度。

时钟节拍:

        时钟节拍(clock tick)是指计算机系统中用作时间基准的最小时间单位。它是硬件时钟发出信号的速度决定的,通常以固定的时间间隔重复。每个时钟节拍都表示一个离散的时间刻度。

        时钟节拍在操作系统中被广泛使用,用于计算和测量时间,以及进行各种时间相关的操作。它是操作系统中任务调度、定时器、延时操作和其他时间相关功能的基础。

        时钟节拍的间隔可以根据系统的需求而不同,通常以毫秒为单位。例如,一个操作系统可能以每秒100个节拍的速度发出时钟信号,这意味着每个时钟节拍的时间间隔为10毫秒。

        通过时钟节拍,操作系统可以跟踪任务的执行时间、处理延时操作、进行定时器计时、实现任务调度等。任务的调度和切换通常是基于时钟节拍的,使得任务能够按照规定的时间片或优先级进行切换。

        总之,时钟节拍是计算机系统中用作时间基准的最小时间单位,它在操作系统中被广泛应用于任务调度、定时器和其他时间相关功能。

        通过之前的学习,我们已经知道,FreeRTOS 中的调度方式就是时间片调度。下图展示了运行在同一优先级下的执行时间图,在优先级 N 下有 3 个就绪的任务。

1. 任务 3 正在运行

2. 这时一个时钟节拍中断发生(滴答定时器中断),任务 3 的时间片用完,但是任务 3 还没有执行完

3. FreeRTOS 将任务切换到任务 1 ,任务 1 是优先级 N 下一个就绪任务

4. 任务 1 连续运行至时间片用完

5. 任务 3 再次获得 CPU 的使用权,接着运行

6. 任务 3 运行完成,但是没有用完时间片,调用任务切换函数 portYIELD() 强行进行任务切换放弃剩余的时间片,从而使优先级 N 下的下一个就绪的任务运行。

7. FreeRTOS 切换到任务 1 

8. 任务 1 执行完其时间片

        要使用时间片调度的话宏 configUSE_PREEMPTION 和宏 configUSE_TIME_SLICING 必须为 1 。 时间片的长度由宏 configTICK_PATE_HZ 来确定,一个时间片的长度就是滴答定时器的中断周期,比如说本教程中 configTICK_PATE_HZ 为 1000,那么一个时间片的长度就是 1ms。时间片调度发生在滴答定时器的中断服务函数中,在中断服务函数中 SysTick_Handler() 会调用 FreeRTOS 的 API 函数 xPortSysTickHandler(),而函数 xPortSysTickHandler() 会引发任务调度,但是是有条件的,函数 xPortSysTickHandler() 如下:

void xPortSysTickHandler( void ) 
{ 
    vPortRaiseBASEPRI(); 
    { 
        if( xTaskIncrementTick() != pdFALSE )
        { 
             portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; 
        } 
    } 
    vPortClearBASEPRIFromISR(); 
}

        上述代码中的 if 判断语句的核心内容为只有函数 xTaskIncrementTick() 的返回值不为 pdFALSE 时,才会进行任务调度!所以接下来再来看函数 xTaskIncrementTick() 。

BaseType_t xTaskIncrementTick( void ) 
{ 
    TCB_t * pxTCB; 
    TickType_t xItemValue; 
    BaseType_t xSwitchRequired = pdFALSE; 
 
    if( uxSchedulerSuspended == ( UBaseType_t ) pdFALSE ) 
    { 
        /***************************************************************************/ 
        /***************************此处省去一大堆代码******************************/ 
        /***************************************************************************/ 
        #if ( ( configUSE_PREEMPTION == 1 ) && ( configUSE_TIME_SLICING == 1 ) )      (1) 
        { 
            if( listCURRENT_LIST_LENGTH( &( \ 
              pxReadyTasksLists[ pxCurrentTCB->uxPriority ] ) ) > ( UBaseType_t ) 1 ) (2) 
            { 
                xSwitchRequired = pdTRUE;                                             (3) 
            } 
            else 
            { 
                mtCOVERAGE_TEST_MARKER(); 
            } 
        } 
    #endif /* ( ( configUSE _PREEMPTION == 1 ) && ( configUSE_TIME_SLICING == 1 ) ) 
    } 
    return xSwitchRequired; 
}

        (1)、当宏 configUSE_PREEMPTION 和宏 configUSE_PREEMPTION 都为 1 的时候下面的代码才会被编译。所以要想使用时间片调度的话这两个宏都必须为 1 ,缺一不可!

        (2)、判断当前任务所对应的优先级下是否还有其他的任务。

        (3)、如果当前任务所对应的任务优先级下还有其他的任务那么就返回 pdTRUE。

        从上面的代码可以看出,如果当前任务所对应的优先级下有其他的任务存在,那么函数 xTaskIncrementTick() 就 会 返 回 pdTURE , 由于函数返 回值为 pdTURE 因此函数 xPortSysTickHandler()就会进行一次任务切换。(简单来说就是:如果同一优先级下的列表中没有其余任务,那就没必要进入 if 判断语句了,也就没必要进行任务调度了)

6. 时间片调度实验

实验设计:

        本实验设计三个任务:start_task、task1_task 和 task2_task,其中 task1_task 和 task2_task 的任务优先级相同,都为 2 ,这三个任务的任务功能如下:

        start_task:用来创建其他两个任务。

        task1_task:控制 LED0 灯闪烁,并且通过串口打印 task1_task 的运行次数。

        task2_task:控制 LED1 灯闪烁,并且通过串口打印 task2_task 的运行次数。

系统设置:

        为了观察方便,将系统的时钟节拍设置为 20 ,也就是将宏 configTICK_RATE_HZ 设置为 20;

#define configTICK_RATE_HZ (20) 

        这样设置以后滴答定时器的中断周期就是 50ms了,也就是说时间片的值为 50 ms,事实上,这个时间片还是很大的,这么设置也是为了我们观察更加方便。

这里解释一下为什么将宏设置为 20 ,得到的滴答定时器的中断周期就是 50ms ?

设置系统的时钟节拍频率为20意味着每秒钟会有20个时钟节拍。要计算出滴答定时器的中断周期,我们可以使用以下公式:

中断周期 = (1 / 时钟节拍频率) * 1000 ms

将时钟节拍频率设置为20,代入公式中:

中断周期 = (1 / 20) * 1000 ms = 50 ms

因此,设置系统的时钟节拍频率为20将导致滴答定时器的中断周期为50毫秒。

这是因为滴答定时器通常被用于周期性地触发操作系统的任务调度和其他时间相关的操作。系统的时钟节拍频率决定了滴答定时器的触发速度,即中断的频率。当时钟节拍频率设置为20时,每秒产生20次中断,因此中断周期为50毫秒。

根据中断周期,操作系统可以完成对任务的切换、定时器的更新、延时操作的计时等。这样的设置可以提供一种适当的时间精度和响应度,以满足系统的需求。

6.1 实验程序

        注意:为了方便观察,需要将宏 #define configTICK_RATE_HZ (20)  设置为 20。(该宏在头文件 FreeRTOSConfig.h 中定义

#include "stm32f4xx.h"  
#include "FreeRTOS.h" //这里注意必须先引用FreeRTOS的头文件,然后再引用task.h
#include "task.h"     //存在一个先后的关系
#include "LED.h"
#include "LCD.h"
#include "usart.h"
#include "delay.h"

//任务优先级
#define START_TASK_PRIO     1
//任务堆栈大小
#define START_STK_SIZE      128
//任务句柄
TaskHandle_t StartTask_Handler;
//任务函数
void start_task(void *pvParameters);

//这里创建两个任务,两个任务的任务优先级相同都为 2
//那么两个任务 task1 和 task2 会处于同一个列表下,
//两个任务进行抢占式调度

//任务优先级
#define TASK1_TASK_PRIO     2
//任务堆栈大小
#define TASK1_STK_SIZE      128
//任务句柄
TaskHandle_t Task1Task_Handler;
//任务函数
void task1_task(void *pvParameters);

//任务优先级
#define TASK2_TASK_PRIO     2
//任务堆栈大小
#define TASK2_STK_SIZE      128
//任务句柄
TaskHandle_t Task2Task_Handler;
//任务函数
void task2_task(void *pvParameters);

int main(void)
{
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4); //设置系统中断优先级分组4
    delay_init(168);
    uart_init(115200);
    LED_Init();
    LCD_Init();
    
    POINT_COLOR=RED;
    LCD_ShowString(30,30,200,16,16,"ATK STM32F407");
    LCD_ShowString(30,50,200,16,16,"FreeRTOS Experiment");
    LCD_ShowString(30,70,200,16,16,"FreeRTOS Round Robin"); //时间片轮转
    LCD_ShowString(30,90,200,16,16,"ATOM@ALIENTEK");
    LCD_ShowString(30,110,200,16,16,"2023/07/01");
    
    //创建开始任务
    xTaskCreate((TaskFunction_t)start_task,             //任务函数
                (const char*   )"start_task",           //任务名称
                (uint16_t      )START_STK_SIZE,         //任务堆栈大小
                (void*         )NULL,                   //传递给任务函数的参数
                (UBaseType_t   )START_TASK_PRIO,        //任务优先级
                (TaskHandle_t* )&StartTask_Handler);    //任务句柄
    vTaskStartScheduler();    //开启任务调度
}

void start_task(void *pvParameters)
{
    taskENTER_CRITICAL();  //进入临界区
    //创建task1任务
    xTaskCreate((TaskFunction_t)task1_task,             //任务函数
                (const char*   )"task1_task",           //任务名称
                (uint16_t      )TASK1_STK_SIZE,         //任务堆栈大小
                (void*         )NULL,                   //传递给任务函数的参数
                (UBaseType_t   )TASK1_TASK_PRIO,        //任务优先级
                (TaskHandle_t* )&Task1Task_Handler);    //任务句柄
    //创建task2任务
    xTaskCreate((TaskFunction_t)task2_task,             //任务函数
                (const char*   )"task2_task",           //任务名称
                (uint16_t      )TASK2_STK_SIZE,         //任务堆栈大小
                (void*         )NULL,                   //传递给任务函数的参数
                (UBaseType_t   )TASK2_TASK_PRIO,        //任务优先级
                (TaskHandle_t* )&Task2Task_Handler);    //任务句柄
    vTaskDelete(StartTask_Handler); //删除开始任务
    taskEXIT_CRITICAL();  //退出临界区
}

//Task1 任务函数
void task1_task(void *pvParameters)
{
    u8 task1_num=0; //用来记录 task1 函数执行次数的变量
    while(1)
    {
        task1_num++; //task1 加到255会自动清零
        //一个8位无符号整数的范围是0到255(2^8-1)。
        //当任务函数执行次数达到255时,再进行递增操作会导致溢出(overflow),即计数器会重新从0开始计数。
        LED0=!LED0;
        taskENTER_CRITICAL();
        //在FreeRTOS中,进入临界区(也称为临界段或关中断)是一种保护关键代码片段不受并发访问干扰的机制。
        //进入临界区可以确保在临界区代码执行期间不会发生任务切换或中断处理,从而保证这部分代码的原子性和一致性。
        printf("任务1已经执行:%d次\r\n",task1_num);//也就说进入临界区是为了保证以下打印的程序不会被干扰中断
        taskEXIT_CRITICAL();
        delay_xms(10);  //延时10ms,模拟任务运行10ms,此函数不会引起任务调度
        //调用函数 delay_xms(10) 延时10ms,在一个时间片内如果任务不主动放弃 CPU 使用权,那么这个任务会一直
        //运行下去,直到时间片耗尽。在 task1_task 任务中我们通过串口打印字符串的方式来提示任务 1 在运行,
        //但是任务运行过程对于 CPU 来说是非常快的,不利于观察,所以这里我们调用函数 delay_xms 来延迟10ms
        //这个函数是不会引起任务调度的,这样的话相当于 task1_task的执行周期大于 10ms ,一个时间片的长度为 50ms
        //delay_xms 占用10ms,如果任务所需的时间以10ms来计算,那么一个时间片内任务1可以执行5次,实际上,很少能
        //执行5次,一般都是4次;
    }
}

//Task2 任务函数
void task2_task(void *pvParameters)
{
    u8 task2_num=0; //用来记录 task2 函数执行次数的变量
    while(1)
    {
        task2_num++; //task2 加到255会自动清零
        //一个8位无符号整数的范围是0到255(2^8-1)。
        //当任务函数执行次数达到255时,再进行递增操作会导致溢出(overflow),即计数器会重新从0开始计数。
        LED1=!LED1;
        taskENTER_CRITICAL();
        //在FreeRTOS中,进入临界区(也称为临界段或关中断)是一种保护关键代码片段不受并发访问干扰的机制。
        //进入临界区可以确保在临界区代码执行期间不会发生任务切换或中断处理,从而保证这部分代码的原子性和一致性。
        printf("任务2已经执行:%d次\r\n",task2_num);//也就说进入临界区是为了保证以下打印的程序不会被干扰中断
        taskEXIT_CRITICAL();
        delay_xms(10);  //延时10ms,模拟任务运行10ms,此函数不会引起任务调度
    }
}


        通过串口的信息可以显示:不管是任务 1 还是任务 2 都是连续执行 4,5 次,这说明在一个时间片内一直运行一个任务,当时间片用完后就切换到下一个任务运行。

  • 5
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值