FreeRTOS 任务切换分析
文章由 FreeRTOS 系列博客整理而来,仅为学习记录,如有不妥,请告知。
FreeRTOS 任务切换分析
FreeRTOS 任务相关的代码大约占总代码的一半左右,这些代码都在为一件事情而努力,即找到优先级最高的就绪任务,并使之获得 CPU 运行权。任务切换是这一过程的直接实施者,为了更快的找到优先级最高的就绪任务,任务切换的代码通常都是精心设计的,甚至会用到汇编指令或者与硬件相关的特性,比如 Cortex-M3 的 CLZ 指令。因此任务切换的大部分代码是由硬件移植层提供的,不同的平台,实现方法也可能不同,这篇文章以 Cortex-M3 为例,讲述 FreeRTOS 任务切换的过程。
FreeRTOS 有两种方法触发任务切换:
- 执行系统调用,比如普通任务可以使用
taskYIELD()
强制任务切换,中断服务程序中使用portYIELD_FROM_ISR()
强制任务切换; - 系统节拍时钟中断
对于 Cortex-M3 平台,这两种方法的实质是一样的,都会使能一个 PendSV 中断,在 PendSV 中断服务程序中,找到最高优先级的就绪任务,然后让这个任务获得 CPU 运行权,从而完成任务切换。
对于第一种任务切换方法,不管是使用 taskYIELD()
还是 portYIELD_FROM_ISR()
,最终都会执行宏 portYIELD()
,这个宏的定义如下:
#define portYIELD() \
{ \
/*产生PendSV中断*/ \
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \
}
对于第二种任务切换方法,在系统节拍时钟中断服务函数中,首先会更新 tick 计数器的值、查看是否有任务解除阻塞,如果有任务解除阻塞的话,则使能 PandSV 中断,代码如下所示:
void xPortSysTickHandler( void )
{
/* 设置中断掩码 */
vPortRaiseBASEPRI();
{
/* 增加tick计数器值,并检查是否有任务解除阻塞 */
if( xTaskIncrementTick() != pdFALSE )
{
/* 需要任务切换。产生PendSV中断 */
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
}
}
vPortClearBASEPRIFromISR();
}
从上面的代码中可以看出,PendSV 中断的产生是通过代码:portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT 实现的,它向中断状态寄存器 bit28 位写入 1,将 PendSV 中断设置为挂起状态,等到优先级高于 PendSV 的中断执行完成后,PendSV 中断服务程序将被执行,进行任务切换工作。
Cortex-M3 架构下,PendSV 中断服务程序源码如下所示,这篇文章重点分析这段代码。
__asm void xPortPendSVHandler( void )
{
extern uxCriticalNesting;
extern pxCurrentTCB; /* 指向当前激活的任务 */
extern vTaskSwitchContext;
PRESERVE8
mrs r0, psp /* PSP内容存入R0 */
isb /* 指令同步隔离,清流水线 */
ldr r3, =pxCurrentTCB /* 当前激活的任务TCB指针存入R2 */
ldr r2, [r3]
stmdb r0!, {r4-r11} /* 保存剩余的寄存器,异常处理程序执行前,硬件自动将xPSR、PC、LR、R12、R0-R3入栈 */
str r0, [r2] /* 将新的栈顶保存到任务TCB的第一个成员中 */
stmdb sp!, {r3, r14} /* 将R3和R14临时压入堆栈,因为即将调用函数vTaskSwitchContext,调用函数时,返回地址自动保存到R14中,所以一旦调用发生,R14的值会被覆盖,因此需要入栈保护; R3保存的当前激活的任务TCB指针(pxCurrentTCB)地址,函数调用后会用到,因此也要入栈保护*/
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY /* 进入临界区 */
msr basepri, r0
dsb /* 数据和指令同步隔离 */
isb
bl vTaskSwitchContext /* 调用函数,寻找新的任务运行,通过使变量pxCurrentTCB指向新的任务来实现任务切换 */
mov r0, #0 /* 退出临界区*/
msr basepri, r0
ldmia sp!, {r3, r14} /* 恢复R3和R14*/
ldr r1, [r3]
ldr r0, [r1] /* 当前激活的任务TCB第一项保存了任务堆栈的栈顶,现在栈顶值存入R0*/
ldmia r0!, {r4-r11} /* 出栈*/
msr psp, r0
isb
bx r14 /* 异常发生时,R14中保存异常返回标志,包括返回后进入线程模式还是处理器模式、使用PSP堆栈指针还是MSP堆栈指针,当调用 bx r14指令后,硬件会知道要从异常返回,然后出栈,这个时候堆栈指针PSP已经指向了新任务堆栈的正确位置,当新任务的运行地址被出栈到PC寄存器后,新的任务也会被执行。*/
nop
}
为了便于理解上面的代码,我们先用流程图的方式将整个过程画出来,然后再逐句分析代码。因为图形可以简化程序,并且信息更容易接受。
先强调图 1-1中的几个术语,首先是“主堆栈指针 MSP”和“进程堆栈指针 PSP”。对于 Cortex-M3 硬件,当系统复位后,默认使用 MSP 指针。MSP 指针用于操作系统内核以及处理异常(也就是说中断服务程序中默认强制使用 MSP 指针,这是硬件自动设置的)。任务(进程)使用 PSP 指针,操作系统负责从 MSP 指针切换到 PSP 指针。这个过程在《FreeRTOS(15)—FreeRTOS 调度器启动过程分析》一文的最后部分中进行了讲解:在 SVC 中断服务程序中启动第一个任务,当从 SVC 中断服务退出前,通过向r14寄存器最后4位按位或上 0x0D,使得硬件在退出时使用进程堆栈指针 PSP 完成出栈操作并返回后进入线程模式、返回Thumb状态。
其次,“堆栈”和“任务堆栈”也值得强调一下。每个任务都有自己的“任务堆栈”,在任务创建时会创建指定大小的任务堆栈,这是任务能够独立运行的前提条件之一。在任务中定义的局部变量,会优先使用寄存器,寄存器不够时就使用任务堆栈的空间。如果在任务中调用其它函数,则调用前的保存信息也存到任务堆栈中去。根据任务代码来估算任务堆栈的大小是件十分重要的技能。前面也说了,Cortex-M3 硬件有两个堆栈指针,操作系统内核以及异常处理程序中使用 MSP 指针,所以它们也需要一个堆栈空间,我们称之为“堆栈”,这个堆栈空间和任务堆栈空间在物理上是绝对不可以重叠的,图 1-2展示了一个编译好的程序可能的 RAM 分配情况(堆栈向下生长)。
有了上面的基础,接下来我们来分析 PendSV 中断服务程序。
mrs r0, psp
是将任务堆栈指针 PSP 的值保存到寄存器 R0 中,因为接下来我们会将寄存器 R4~R11 也保存到任务堆栈中,但是我们没有哪个汇编指令能直接操作 PSP 完成入栈,所以只能借助 R0。
ldr r3, =pxCurrentTCB /* 当前激活的任务TCB指针存入R2 */
ldr r2, [r3]
这两句代码是获取当前激活的任务 TCP 指针,指针 pxCurrentTCB
前面文章已经提到过很多次了,它是位于 tasks.c
文件中定义的唯一一个全局指针型变量,指向当前激活的任务 TCB。
stmdb r0!, {r4-r11}
这句代码用于将寄存器 R4~R11 保存到当前激活的程序任务堆栈中,并且同步更新寄存器R0的值。
str r0, [r2]
寄存器 R2 中保存当前激活的任务 TCB 指针,在《 FreeRTOS(14)—FreeRTOS 任务创建分析》中讲任务 TCB 数据结构时我们知道,任务 TCB 数据结构第一个成员一定是指向任务当前堆栈栈顶的指针变量 pxTopOfStack。这句代码将 R0 的内容保存到任务 TCB 数据结构的第一个成员 pxTopOfStack
中,也就是将最新的任务堆栈指针保存到任务 TCB 的 pxTopOfStack 字段中。当任务被激活时,就是从这个字段中获取任务堆栈指针,然后完成数据出栈操作的。
stmdb sp!, {r3, r14}
将 R3 和 R14 临时压入堆栈,因为即将调用函数 vTaskSwitchContext
。调用函数时,返回地址自动保存到 R14 中,所以一旦调用发生,R14 的值会被覆盖,因此需要入栈保护。R3 保存的当前激活的任务 TCB 指针(pxCurrentTCB
)地址,函数调用后会用到,因此也要入栈保护。
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
msr basepri, r0
这两句代码用来进入临界区,中断优先级大于等于 configMAX_SYSCALL_INTERRUPT_PRIORITY
的中断都会被屏蔽。
bl vTaskSwitchContext
调用函数,选择下一个要执行的任务,也就是寻找处于就绪态的最高优先级任务。变量 pxCurrentTCB
指向找到的任务 TCB。这个函数是核心中的核心,所有的其它代码都是为了保证这个函数能正确运行。
某些运行 FreeRTOS 的硬件有两种方法:通用方法和特定于硬件的方法(以下简称“特殊方法”)。
-
对于通用方法:
configUSE_PORT_OPTIMISED_TASK_SELECTION
设置为 0 或者硬件不支持这种特殊方法。- 可以用于所有 FreeRTOS 支持的硬件。
- 完全用 C 实现,效率略低于特殊方法。
- 不强制要求限制最大可用优先级数目
-
对于特殊方法:
- 并非所有硬件都支持。
- 必须将
configUSE_PORT_OPTIMISED_TASK_SELECTION
设置为 1。 - 依赖一个或多个特定架构的汇编指令(一般是类似计算前导零 [CLZ] 指令)。
- 比通用方法更高效。
- 一般强制限定最大可用优先级数目为 32(0~31)。
Cortex-M3 即支持通用方法也支持特殊方法,默认的移植层使用特殊方法。我们先来看一下通用方法如何找到下一个要执行的任务。
在函数 vTaskSwitchContext
中使用宏 taskSELECT_HIGHEST_PRIORITY_TASK()
完成任务寻址工作,使用通用方法时,这个宏的代码如下所示。pxReadyTasksLists
是定义在 tasks.c
中的静态列表数组,表示就绪任务列表数组。在《 FreeRTOS(14)—FreeRTOS 任务创建分析》中讲过这个变量:新创建任务的过程中,任务TCB中的状态列表项 xStateListItem
会挂接到就绪任务列表数组中。uxTopReadyPriority
也是定义在 tasks.c
中的静态变量,在此之前,它已经代表处于就绪态任务的最高优先级值,在 FreeRTOS 任务创建与分析一文中,我们也讲到了这个变量:每次任务创建,都会判断新任务的优先级是否大于这个变量,如果大于,还会更新这个变量的值。
while() 循环从优先级 uxTopReadyPriority
开始,从就绪列表数组 pxReadyTasksLists
中找出优先级最高的任务,然后调用宏 listGET_OWNER_OF_NEXT_ENTRY
获取最高优先级列表中的下一个列表项,并从该列表项中获取任务 TCB 指针赋给变量pxCurrentTCB。
#define taskSELECT_HIGHEST_PRIORITY_TASK() \
{ \
/* 从就绪列表数组中找出最高优先级列表*/ \
while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopReadyPriority ] ) ) ) \
{ \
configASSERT( uxTopReadyPriority ); \
--uxTopReadyPriority; \
} \
\
/* 相同优先级的任务使用时间片共享处理器就是通过这个宏实现*/ \
listGET_OWNER_OF_NEXT_ENTRY(pxCurrentTCB, &( pxReadyTasksLists[ uxTopReadyPriority ] ) ); \
} /* taskSELECT_HIGHEST_PRIORITY_TASK */
对于 Cortex-M3 硬件,还支持特殊方法选择下一个要执行的任务,那就是利用硬件提供的计算前导零指令 CLZ。特殊方法时,宏 taskSELECT_HIGHEST_PRIORITY_TASK()
的代码如下所示。
#define taskSELECT_HIGHEST_PRIORITY_TASK() \
{ \
UBaseType_t uxTopPriority; \
\
/* 从就绪列表数组中找出最高优先级列表*/ \
portGET_HIGHEST_PRIORITY( uxTopPriority, uxTopReadyPriority ); \
listGET_OWNER_OF_NEXT_ENTRY(pxCurrentTCB, &( pxReadyTasksLists[ uxTopPriority ] ) ); \
} /* taskSELECT_HIGHEST_PRIORITY_TASK() */
与通用方法相比,可以发现从就绪列表数组中找出最高优先级列表代码不同了,特殊方法使用宏 portGET_HIGHEST_PRIORITY
来实现,将宏定义替换后,代码为:
uxTopPriority = ( 31UL - ( uint32_t ) __clz( (uxTopReadyPriority) ) )
在此之前,静态变量 uxTopReadyPriority
同样已经包含处于就绪态任务的最高优先级的信息。与通用方法中使用任务优先级数值不同,在特殊方法中,uxTopReadyPriority
使用每一位来表示任务,比如变量 uxTopReadyPriority
的 bit0 为 1,则表示存在优先级为0的就绪任务,bit10 为 1 则表示存在优先级为 10 的就绪任务。由于 32 位整形数最多只有 32 位,因此使用这种特殊方法限定最大可用优先级数目为 32,即优先级0~31。
我们这来看看 __clz( (uxTopReadyPriority) 是什么意思,__clz() 会被汇编指令 CLZ 替换掉,这个指令用来计算一个变量从最高位开始的连续零的个数。举个例子,假如变量 uxTopReadyPriority
为 0x09(二进制为:0000 0000 0000 0000 0000 0000 0000 1001),即 bit3 和 bit0 为 1,表示存在优先级为0和3的就绪任务。则 __clz( (uxTopReadyPriority)的值为28,uxTopPriority =31-28=3,即优先级为 3 的任务是就绪态最高优先级任务。下面的代码跟通用方法一样,调用宏 listGET_OWNER_OF_NEXT_ENTRY
获取最高优先级列表中的下一个列表项,并从该列表项中获取任务 TCB 指针赋给变量pxCurrentTCB
。
mov r0, #0 /* 退出临界区*/
msr basepri, r0
这两句代码用来退出临界区,通过向寄存器 BASEPRI 写入数值 0 来实现。
ldmia sp!, {r3, r14}
这句代码将寄存器 R3 和 R14 从堆栈中恢复,现在 R3 保存变量 pxCurrentTCB
的地址,需要注意的是,变量 pxCurrentTCB
在函数 vTaskSwitchContext
中可能已被修改,指向新的最高优先级就绪任务;R14 保存退出异常需要的信息。
ldr r1, [r3]
ldr r0, [r1]
这两句代码获取变量 pxCurrentTCB
指向的任务 TCB 指针,并将 TCB 的第一个成员——当前堆栈栈顶的指针变量 pxTopOfStack
的值保存到寄存器 R0 中,也就是将即将运行的任务堆栈栈顶值存入 R0。
ldmia r0!, {r4-r11}
将寄存器 R4~R11 出栈,并同时更新 R0 的值。
msr psp, r0
将最新的任务堆栈栈顶赋值给线程堆栈指针 PSP。
bx r14
从异常中断服务程序退出。异常发生时,R14 中保存异常返回标志,包括返回后进入线程模式还是处理器模式、使用 PSP 堆栈指针还是 MSP 堆栈指针。当调用 bx r14 指令后,硬件会知道要从异常返回,然后出栈,这个时候堆栈指针 PSP 已经指向了新任务堆栈的正确位置,当新任务的运行地址被出栈到 PC 寄存器后,新的任务也会被执行。
至此,任务切换完成。