SVC中断和 PendSV 中断

SVC中断

用于产生系统函数的调用请求。例如操作系统不让用户程序直接访问硬件,而是通过提供一些系统服务函数,用户程序使用SVC 发出对系统服务函数的呼叫请求,以这种方法调用它们来间接访问硬件。因此,当用户程序想要控制特定的硬件时,它就会产生一个SVC异常,然后操作系统提供的SVC异常服务例程得到执行,它再调用相关的操作系统函数,后者完成用户程序请求的服务。
09.任务切换 - 图1

PendSV 中断

和SVC协同使用。SVC异常必须立即得到响应(如果因优先级不比当前正处理的高,或是其他原因使之无法立即响应,则上访成硬fault)。而PendSV不同,可以像普通中断一样被悬起(不会像SVC那样会上访)。OS可以利用它缓期执行一个异常,知道其他重要的任务完成后才执行动作。
PendSV典型应用:上下文切换。

PendSV上下文切换

如果一个系统通过Systick异常启动上下文切换,则可能出现正在响应另一个异常,Systick会抢占ISR(Interrupt Service Routines,中断服务程序)。OS不允许中断过程中执行上下文切换。如下图。
09.任务切换 - 图2
而PendSV能悬起,很好的解决该问题。
09.任务切换 - 图3

FreeRTOS 任务切换场合

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

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

    执行系统调用

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

    1. #define taskYIELD() portYIELD()

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

    1. #define portYIELD() \
    2. { \
    3. portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \
    4. __dsb( portSY_FULL_READ_WRITE ); \
    5. __isb( portSY_FULL_READ_WRITE ); \
    6. }

    portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT就是向中断控制和壮态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中断。这样就可以在 PendSV 中断服务函数中进行任务切换了。
    中断级的任务切换函数为 portYIELD_FROM_ISR()。

    1. #define portEND_SWITCHING_ISR( xSwitchRequired ) if( xSwitchRequired != pdFALSE ) portYIELD()
    2. #define portYIELD_FROM_ISR( x ) portEND_SWITCHING_ISR( x )

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

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

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

    1. void SysTick_Handler(void)
    2. {
    3. if(xTaskGetSchedulerState()!=taskSCHEDULER_NOT_STARTED)//系统已经运行
    4. {
    5. xPortSysTickHandler();
    6. }
    7. HAL_IncTick();
    8. }

    在滴答定时器中断服务函数中调用了 FreeRTOS 的 API 函数 xPortSysTickHandler()。

    1. void xPortSysTickHandler( void )
    2. {
    3. //关闭中断
    4. vPortRaiseBASEPRI();
    5. {
    6. //增加时钟计数器 xTickCount 的值
    7. if( xTaskIncrementTick() != pdFALSE )
    8. {
    9. //通过向中断控制和壮态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中断。这样就可以在 PendSV 中断服务函数中进行任务切换了。
    10. portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
    11. }
    12. }
    13. //打开中断
    14. vPortClearBASEPRIFromISR();
    15. }

    PendSV 中断服务函数

    FreeRTOS 任务切换的具体过程是在 PendSV 中断服务函数中完成的,PendSV 中断服务函数本应该为 PendSV_Handler(),但是 FreeRTOS 使用#define 重定义了。

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

    查找下一个要运行的任务

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

    1. void vTaskSwitchContext( void )
    2. {
    3. //如果调度器挂起那就不能进行任务切换。
    4. if( uxSchedulerSuspended != ( UBaseType_t ) pdFALSE )
    5. {
    6. xYieldPending = pdTRUE;
    7. }
    8. else
    9. {
    10. xYieldPending = pdFALSE;
    11. traceTASK_SWITCHED_OUT();
    12. taskCHECK_FOR_STACK_OVERFLOW();
    13. //调用函数 taskSELECT_HIGHEST_PRIORITY_TASK()获取下一个要运行的任务。taskSELECT_HIGHEST_PRIORITY_TASK()本质上是一个宏,在 tasks.c 中有定义
    14. taskSELECT_HIGHEST_PRIORITY_TASK();
    15. traceTASK_SWITCHED_IN();
    16. }
    17. }

    FreeRTOS 中查找下一个要运行的任务有两种方法:一个是通用的方法,另外一个就是使用硬件的方法,这个在我们讲解 FreeRTOSCofnig.h 文件的时候就提到过了,至于选择哪种方法通过宏 configUSE_PORT_OPTIMISED_TASK_SELECTION 来决定的。当这个宏为 1 的时候就使用硬件的方法,否则的话就是使用通用的方法

    通用方法

    所有的处理器都可以用的方法。

    1. #define taskSELECT_HIGHEST_PRIORITY_TASK() \
    2. { \
    3. UBaseType_t uxTopPriority = uxTopReadyPriority; \
    4. while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) ) \
    5. { \
    6. configASSERT( uxTopPriority ); \
    7. --uxTopPriority; \
    8. } \
    9. listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, \
    10. &( pxReadyTasksLists[ uxTopPriority ] ) ); \
    11. uxTopReadyPriority = uxTopPriority; \
    12. }

    pxReadyTasksLists[]为就绪任务列表数组,一个优先级一个列表,同优先级的就绪任务都挂到相对应的列表中。uxTopReadyPriority 代表处于就绪态的最高优先级值,每次创建任务的时候都会判断新任务的优先级是否大于 uxTopReadyPriority,如果大于的话就将这个新任务的优先级赋值给变量 uxTopReadyPriority。函数 prvAddTaskToReadyList()也会修改这个值,也就是说将某个任务添加到就绪列表中的时候都会用 uxTopReadyPriority 来记录就绪列表中的最高优先级。这里就从这个最高优先级开始判断,看看哪个列表不为空就说明哪个优先级有就绪的任务。函数 listLIST_IS_EMPTY()用于判断某个列表是否为空,uxTopPriority 用来记录这个有就绪任务的优先级。
    已经找到了有就绪任务的优先级了,接下来就是从对应的列表中找出下一个要运行的任务,查找方法就是使用函数 listGET_OWNER_OF_NEXT_ENTRY()来获取列表中的下一个列表项,然后将获取到的列表项所对应的任务控制块赋值给 pxCurrentTCB,这样我们就确定了下一个要运行的任务了。

    硬件方法

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

    1. #define taskSELECT_HIGHEST_PRIORITY_TASK() \
    2. { \
    3. UBaseType_t uxTopPriority; \
    4. portGET_HIGHEST_PRIORITY( uxTopPriority, uxTopReadyPriority ); \
    5. configASSERT( listCURRENT_LIST_LENGTH( & \
    6. ( pxReadyTasksLists[ uxTopPriority ] ) )> 0 ); \
    7. listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, \
    8. &( pxReadyTasksLists[ uxTopPriority ] ) ); \
    9. }

    通 过 函 数 portGET_HIGHEST_PRIORITY() 获 取 处 于 就 绪 态 的 最 高 优 先 级 ,portGET_HIGHEST_PRIORITY 本质上是个宏

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

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

  • 二进制数 1000 0000 0000 0000 的前导零个数就为 0。

  • 二进制数 0000 1001 1111 0001 的前导零个数就是 4。

得到 uxTopReadyPriority 的前导零个数以后在用 31 减去这个前导零个数得到的就是处于就绪态的最高优先级了,比如优先级 30 为此时的处于就绪态的最高优先级,30 的前导零个数为1,那么 31-1=30,得到处于就绪态的最高优先级为 30。
已经找到了处于就绪态的最高优先级了,接下来就是从对应的列表中找出下一个要运行的任务,查找方法就是使用函数 listGET_OWNER_OF_NEXT_ENTRY()来获取列表中的下一个列表项,然后将获取到的列表项所对应的任务控制块赋值给 pxCurrentTCB,这样我们就确定了下一个要运行的任务了。
可以看出硬件方法借助一个指令就可以快速的获取处于就绪态的最高优先级,但是会限制任务的优先级数,比如 STM32 只能有 32 个优先级,不过 32 个优先级已经完全够用了。要知道FreeRTOS 是支持时间片的,每个优先级可以支持无限多个任务。

FreeRTOS 时间片调度

FreeRTOS 支持多个任务同时拥有一个优先级,这些任务的调度是一个值得考虑的问题,不过这不是我们要考虑的。在 FreeRTOS 中允许一个任务运行一个时间片(一个时钟节拍的长度)后让出 CPU 的使用权,让拥有同优先级的下一个任务运行。
09.任务切换 - 图4
要使用时间片调度的话宏 configUSE_PREEMPTION 和宏 configUSE_TIME_SLICING 必须为 1。时间片的长度由宏 configTICK_RATE_HZ 来确定,一个时间片的长度就是滴答定时器的中断周期,比如本教程中 configTICK_RATE_HZ 为 1000,那么一个时间片的长度就是 1ms。时间片调度发生在滴答定时器的中断服务函数中,前面讲解滴答定时器中断服务函数的时候说了在中断服务函数 SysTick_Handler()中会调用 FreeRTOS 的 API 函数 xPortSysTickHandler(),而函数 xPortSysTickHandler() 会 引 发 任 务 调 度 , 但 是 这 个 任 务 调 度 是 有 条 件 的 , 函 数xPortSysTickHandler()。

  1. void xPortSysTickHandler( void )
  2. {
  3. //关闭中断
  4. vPortRaiseBASEPRI();
  5. {
  6. //增加时钟计数器 xTickCount 的值
  7. if( xTaskIncrementTick() != pdFALSE )
  8. {
  9. //通过向中断控制和壮态寄存器 ICSR 的 bit28 写入 1 挂起 PendSV 来启动 PendSV 中断。这样就可以在 PendSV 中断服务函数中进行任务切换了。
  10. portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
  11. }
  12. }
  13. //打开中断
  14. vPortClearBASEPRIFromISR();
  15. }

只有函数 xTaskIncrementTick()的返回值不为 pdFALSE 的时候才会进行任务调度。

  1. BaseType_t xTaskIncrementTick( void )
  2. {
  3. TCB_t * pxTCB;
  4. TickType_t xItemValue;
  5. BaseType_t xSwitchRequired = pdFALSE;
  6. if( uxSchedulerSuspended == ( UBaseType_t ) pdFALSE )
  7. {
  8. /***************************************************************************/
  9. /***************************此处省去一大堆代码******************************/
  10. /***************************************************************************/
  11. //当宏 configUSE_PREEMPTION 和宏 configUSE_PREEMPTION 都为 1 的时候下面的代码才会编译。所以要想使用时间片调度的话这这两个宏都必须为 1
  12. #if ( ( configUSE_PREEMPTION == 1 ) && ( configUSE_TIME_SLICING == 1 ) ) {
  13. //判断当前任务所对应的优先级下是否还有其他的任务。
  14. if( listCURRENT_LIST_LENGTH( &( \
  15. pxReadyTasksLists[ pxCurrentTCB->uxPriority ] ) ) > ( UBaseType_t ) 1 )
  16. {
  17. //如果当前任务所对应的任务优先级下还有其他的任务那么就返回 pdTRUE。
  18. xSwitchRequired = pdTRUE;
  19. }
  20. else
  21. {
  22. mtCOVERAGE_TEST_MARKER();
  23. }
  24. }
  25. #endif /* ( ( configUSE_PREEMPTION == 1 ) && ( configUSE_TIME_SLICING == 1 ) )
  26. }
  27. return xSwitchRequired;
  28. }

从上面的代码可以看出,如果当前任务所对应的优先级下有其他的任务存在,那么函数xTaskIncrementTick() 就 会 返 回 pdTURE , 由 于 函 数 返 回 值 为 pdTURE 因此函数xPortSysTickHandler()就会进行一次任务切换。