关于FreeRTOS任务栈的那点事儿
by Jason Yuan
0x00 基础知识
0x00 00 栈指针
一般来说Cortex-M系列有两种工作模式,一种叫做Thread Mode(线程模式),另外一种叫做Handler mode(中断模式)。程序按照编译好的代码执行,Cortex-M就会处于线程模式,一旦它收到中断信号并执行中断处理函数时,它就处于中断模式了。
Cortex-M处理器有两个栈指针,一个叫主栈指针(Main Stack Pointer)(缩写成MSP),一个叫程序指针(Processor Stack Pointer)(缩写成PSP)。MSP用于线程模式,并且在中断模式下只能用MSP。PSP总是用于线程模式。
0x00 01 SVC异常和PendSV异常
FreeRTOS并没有使用SVC异常输入不同的参数,做不同的功能处理。FreeRTOS只是在首次进入任务时调用了SVC异常,且只使用了一次。
PendSV主要用于任务切换,即保存当前任务状态保存和提取下一个任务的状态。在每次SYSTICK异常发生时都会使用指令触发PendSV。当然,也可以在线程模式下主动触发PendSV,进行任务切换。
0x00 02 SYSTICK
因为操作系统把时间划分成了时间片,而SYSTICK的主要作用就是给操作系统提供时间划分。SYSTICK其实是一个Cortex-M内核自带的定时器,给它设定一个时间间隔,它就以该时间间隔不断产生中断。
0x01 栈帧(stack frame)
这部分是FreeRTOS设计的核心,也是所有针对Cortex-M内核的RTOS设计的关键原理。
0x01 00 ARM架构下的C函数实现
大家都知道ARM架构中有一些通用寄存器R0-R15等,C编译器对C函数编译,编译后的汇编会使用到这些通用寄存器。而这些寄存器分成了两类,一类叫做调用者保存的寄存器(caller saved registers),另一类叫做被调用者保存的寄存器(callee-saved registers)。
调用者保存的寄存器包括,R0-R3,R12,LR,PSR。
被调用者保存的寄存器包括,R4-R11。
-
在调用函数前,程序随意使用R4-R11的,因为接下来被调用的函数有义务把R4-R11恢复成调用前的样子。但是如果在调用完函数后还需要使用寄存器(R0-R3,R12,LR,PSR),那么就得先把这些寄存器保存起来,接下来得函数返回后再把这些寄存器恢复。
-
在被调用函数中,可以随意使用(R0-R3,R12,LR,PSR),因为被调用函数对他们的值不负责。而如果要用到R4-R11,被调用函数有责任保存它们的状态。也就是说,被调用函数需要保证R4-R11在进入函数时的值和退出函数时的值是一样的。如果在被调用函数中把这些寄存器值改变了,就一定要把他们恢复成被调用前的样子。
除了以上提到的寄存器外,带浮点型运算单元的Cortex-M4内核还有额外寄存器需要处理。
调用者保存的寄存器包括,S0-S15。
被调用者保存的寄存器包括,S16-S31。
查看通用寄存器,可以发现R13(SP)和R15(PC)没有涉及到。SP在常规的C函数中当然是保存当前的栈地址,不管是调用函数前还是在被调用函数中都要用到SP。使用的栈空间没有变化,所以SP也不用保存了。而在函数调用前,把LR先压入栈中,然后把当前PC传给LR。这样在函数返回时,就会把LR赋值给PC,进而恢复到函数调用前的运行位置。
ARM希望把中断处理函数也做成C函数的形式,那么处理方式就和上文提到的一般C函数的处理过程类似了。进入中断前,首先要把(R0-R3,R12,LR,PSR)保存起来,然后在中断结束后恢复它们。这一切都是通过硬件完成的。
但是,中断的返回地址并没有像一般的C函数调用一样存储在LR中。也就是说,中断过程中不但要像一般的C函数调用一样保存(R0-R3,R12,LR,PSR),还要保存中断返回地址(return address)。中断的硬件机制会把EXC_RETURN放进LR,在中断返回时触发中断返回,而不是一般的C函数返回。
0x01 01 EXC_RETURN
当然,在中断的入栈和出栈过程中还有个填充对齐问题,这个问题对于理解任务切换并不是个关键问题,有兴趣可以自行查找资料。
如上文所说,LR在进入中断后通过硬件更新为EXC_RETURN。
EXC_RETURN为中断返回提供了更多的必要信息,如上表所示。
- bit4,表明了压入的是8个字,还是26个字。因为带浮点运算单元和不带浮点运算单元是有区别的。
- bit3,表明是返回到Thread模式还是Handler模式。也就是该中断之前是从线程模式进入的,还是从中断中进入的(中断嵌套)。
- bit2 返回到哪个栈,是程序栈(Process Stack)还是主栈(Main Stack)。
0x01 02 进入中断和入栈
中断硬件自动入栈如图8.8是嵌套压栈的过程。
第一步,程序在线程模式(Thread Mode)下运行,并使用程序栈(PSP)。
第二步,中断来临后,把寄存器压入栈中。但是使用的是哪个栈,以及压入的是8个字还是26个字,返回的是中断模式(Handler mode)还是线程模式(Thread mode)这部分硬件自动回检测相应寄存器,并生成对应EXC_RETURN填入到LR中。因为使用的是程序栈(PSP),PSP先自减,然后把相应的寄存器压栈。这部分是硬件完成的。
第三步,执行中断服务函数,中断服务函数使用的是主栈(main stack)。
第四步,有了更高优先级的任务后,第三步的中断服务函数也会被打断。此时,使用主栈保存寄存器的状态。也会依据当前的状态来生成相应的EXC_RETURN,以便下次返回。
第五步,执行嵌套的中断服务函数。
0x01 03 中断返回和出栈
中断过程中设置LR为EXC_RETURN 出栈操作出栈操作,其实和入栈是个相反的过程。在退出中断时,使用哪里的数据(MSP或者PSP)恢复寄存器,返回的栈帧模式(带不带FPU,即8个字还是26个字),返回的是线程模式(Thread Mode)还是中断模式(Handler Mode),都是由LR寄存器中的EXC_RETURN决定的。
所以在FreeRTOS的任务切换过程中修改了LR中的EXC_RETURN,以切换处理器到期望的状态。
0x02 FreeRTOS的任务栈操作
0x02 00 栈初始化
StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxCode, void *pvParameters )
{
/* Simulate the stack frame as it would be created by a context switch
interrupt. */
/* Offset added to account for the way the MCU uses the stack on entry/exit
of interrupts, and to ensure alignment. */
pxTopOfStack--;
*pxTopOfStack = portINITIAL_XPSR; /* xPSR */ [1]
pxTopOfStack--;
*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK; /* PC */ [2]
pxTopOfStack--;
*pxTopOfStack = ( StackType_t ) prvTaskExitError; /* LR */[3]
/* Save code space by skipping register initialisation. */
pxTopOfStack -= 5; /* R12, R3, R2 and R1. */ [4]
*pxTopOfStack = ( StackType_t ) pvParameters; /* R0 */ [5]
/* A save method is being used that requires each task to maintain its
own exec return value. */
pxTopOfStack--;
*pxTopOfStack = portINITIAL_EXEC_RETURN; [6]
pxTopOfStack -= 8; /* R11, R10, R9, R8, R7, R6, R5 and R4. */ [7]
return pxTopOfStack;
}
以上一个任务栈的初始化过程。假设已经为任务栈开辟了一块内存,pxTopOfStack指向的是栈顶。Cortex-M系列的栈是由高至低使用的。
[1],栈顶保存了xPSR,且它的值为portINITIAL_XPSR,0x01000000。其实就是一个初始状态,其中的1表示Thumb状态。因为Cortex-M只有Thumb状态。
[2],栈往下存的是PC(Return Address),值为pxCode,其实是任务函数。当从中断(SVC或者PendSV)返回后,这个值会被自动存入到PC,即从任务函数处开始运行。
[3],LR为函数prvTaskExitError,其实是不允许从这返回的。如果使用了这个LR,说明任务函数返回了。正常应该把改任务删除,而不是返回。
[4],为R12, R3, R2 and R1保留位置
[5],R0初始化为pvParameters,也就是任务初始化中的,任务参数。在ARM中,一般R0-R3被用作输入参数。
[6]初始化EXC_RETURN为portINITIAL_EXEC_RETURN,它的值为0XFFFFFFD。它表示压入的栈是8个字,返回线程模式(Thread Mode),且使用程序栈(PSP)。
[7]为R11, R10, R9, R8, R7, R6, R5, R4保留位置。
图8.2是一个不带浮点运算的8字,中断的栈帧状态。其中并没有包含R4-R11,和EXC_RETURN。这是为什么呢?它说的是栈帧,也就值中断发生后硬件自动完成的压栈,而R4-R11,和EXC_RETURN是需要手动保存的状态。
还有一个点要注意,上面的函数是通过调用以下语句完成的。
pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters, xRunPrivileged );
可以看到,它在初始化栈顶数据后,也把栈顶指针更新到了入栈后的状态。现在的栈顶指针指向的是R4。
0x02 01 启动第一个任务
一个任务栈已经被初始化了,FreeRTOS进入第一个任务是通过SVC中断完成的。这个函数是一个中断服务函数,源码如下:
__asm void vPortSVCHandler( void )
{
PRESERVE8
/* Get the location of the current TCB. */
ldr r3, =pxCurrentTCB ;[1]
ldr r1, [r3] ;[2]
ldr r0, [r1] ;[3]
/* Pop the core registers. */
ldmia r0!, {r4-r11, r14} ;[4]
msr psp, r0 ;[5]
isb ;[6]
mov r0, #0 ;[7]
msr basepri, r0 ;[8]
bx r14 ;[9]
}
[1][2][3] :其实是找到当前任务的栈顶指针,赋值给r0。
pxCurrentTCB指向了一个任务控制块,是一个变量。
PRIVILEGED_DATA TCB_t * volatile pxCurrentTCB = NULL;
[1]中的=pxCurrentTCB是pxCurrentTCB的地址,相当于&pxCurrentTCB。所以r3相当于&pxCurrentTCB。
[2]r1获得了变量pxCurrentTCB的值,而pxCurrentTCB是一个指针变量,指向了一个任务控制块。
[3][r1]获得了任务控制块首地址的值,也就是pxTopOfStack的值。
[4] :这条语句的意思是把R0指向地址的值,由低到高分别赋值给r4-r11,和r14.然后r0更新为r14之上的地址。
什么意思呢,上文的栈初始化,可以清楚地看到栈顶指针是指向了R4。整个数据由低到高的排列是r4,r5,r6,r7,r8,r9,r10,r11,EXEC_RETURN,r0,r1,r2,r3,r12,LR,PC(Return Address),xPSR。调用[4]指令后,栈顶指针指向的r4-r11和EXEC_RETURN被保存到了寄存器的r4-r11和r14中了。这里我们并不关心r4-r11,因为初始化的时候就没有初始化他们。我们关心r14,它是链接寄存器LR,把EXEC_RETURN赋值给它了。它的值现在是0XFFFFFFD。这意味着,在SVC中断返回时,栈帧是8字的(bit4 == 1),返回的是线程模式(Thread Mode)(bit3 == 1),使用的栈帧是PSP(bit2 == 1)。
此时的r0寄存器指向栈中(内存)的r0。也就是r0之前指向栈中的r4位置,现在指向栈中的r0位置。
[5]把r0赋值给psp,也就是让程序栈指针(PSP)指向了栈顶,当前的栈顶是由低到高的(r0,r1,r2,r3,r12,LR,PC(Return Address),xPSR)中的r0。
[6]等数据传输完成。
[7][8]: 开中断。
[9] : 当前的r14(LR)是0XFFFFFFD。跳转到这个值就如上面的指令[4]所说的一样。Cortex-M会任务,栈帧是8字的(bit4 == 1),返回的是线程模式(Thread Mode)(bit3 == 1),使用的栈帧是PSP(bit2 == 1)的中断返回。所以紧接着就会把栈中(内存)的(r0,r1,r2,r3,r12,LR,PC(Return Address),xPSR)弹出到相应的寄存器中。压入的过程是硬件自动完成的。弹出后栈顶指针psp就又回到了栈顶,即回到了当初申请到的栈顶位置。
因为弹出栈中保存的PC(Return Address) 到PC寄存器中,而栈中的PC(Return Address)是pxCode,即任务函数。那么程序接着就会运行到任务函数中。
0x02 02 任务切换
对于FreeRTOS来说,任务切换就是整个系统的核心,那么接下来就分析下任务切换的过程。FreeRTOS的任务切换在PendSV中断中完成,分析PendSV中断函数就是关键问题了。
__asm void xPortPendSVHandler( void )
{
extern uxCriticalNesting;
extern pxCurrentTCB;
extern vTaskSwitchContext;
PRESERVE8
mrs r0, psp ;[1]
isb
/* Get the location of the current TCB. */
ldr r3, =pxCurrentTCB ;[2]
ldr r2, [r3] ;[3]
/* Is the task using the FPU context? If so, push high vfp registers. */
tst r14, #0x10 ;[4]
it eq
vstmdbeq r0!, {s16-s31}
/* Save the core registers. */ ;[5]
stmdb r0!, {r4-r11, r14}
/* Save the new top of stack into the first member of the TCB. */
str r0, [r2] [6]
stmdb sp!, {r3} [7]
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY [8]
msr basepri, r0
dsb
isb
bl vTaskSwitchContext [9]
mov r0, #0 [10]
msr basepri, r0
ldmia sp!, {r3} [11]
/* The first item in pxCurrentTCB is the task top of stack. */
ldr r1, [r3] [12]
ldr r0, [r1] [13]
/* Pop the core registers. */ [14]
ldmia r0!, {r4-r11, r14}
/* Is the task using the FPU context? If so, pop the high vfp registers
too. */
tst r14, #0x10 [15]
it eq
vldmiaeq r0!, {s16-s31}
msr psp, r0 [16]
isb
#ifdef WORKAROUND_PMU_CM001 /* XMC4000 specific errata */
#if WORKAROUND_PMU_CM001 == 1
push { r14 }
pop { pc }
nop
#endif
#endif
bx r14 [17]
}
假设现在有两个任务A和B,各自有一个任务栈AS和BS。
前面提到了栈帧的概念,也就是说在进入PenvSV中断前,需要先把一部分寄存器压入到自己的栈中。例如,当前任务A,运行在栈AS中。SYSTICK触发了PenvSV,Cortex-M内核将任务A的寄存器状态压入当栈AS中。
带浮点运算的栈帧
从图8.4可以看出来,带浮点运算单元的Cortex-M4压入了R0,R1,R2,R3,R12,LR,ReturnAddress,xPSR(bit9 == 1),S0,S1...S15,FPSCR。
接着程序运行到了xPortPendSVHandler
[1]把psp存到r0,当前的psp是任务A的栈指针,在进入PendSV前压入了R0,R1等寄存器。这时的psp指向的是任务栈AS中的R0。
[2]获取当前任务控制块的指针。pxCurrentTCB是一个任务控制块的地址。
[3]对pxCurrentTCB解引用后,[r3]的值是任务控制块的第一个元素的值。即r2的值为pxTopOfStack。r2保存了当前AS栈的栈指针。
[4]因为当前是PendSV中断,所以r14(LR)保存的是EXC_RETURN。该指令就是测试bit4是否为1。为1压入的是8字,为0压入的是26字。这句的意思是如果为0,那么CPU的类型是带浮点运算的,那么除了硬件自动保存的(S0,S1...S15,FPSCR),还要手动保存S16-S31。r0的值就是psp,在把S16-S31压入后,r0也相应地向下更新了。
[5]除了自动保存的R0,R1,R2,R3,R12,LR,ReturnAddress,xPSR(bit9 == 1),这里还手动要压入r4-r11, r14(EXC_RETURN)到栈中。这样,所有的寄存器状态都被压入了栈中,下次回来的时候也是这个顺序读取的。
其实将要切换到的任务栈BS也是这个状态保存的。接下来就是找到任务B的栈顶,然后把任务B相关的寄存器读出来。
[6]更新后的任务A栈指针保存到A任务控制块的pxTopOfStack中。下次切换的时候就可以从这开始取出寄存器状态了。
[7]把r3压入到sp。注意了,这里的sp不是psp,因为当前在中断服务函数里面所以使用的是主栈MSP,所以把r3压入到了主栈里面。为什么要压入r3呢,因为r3保存的pxCurrentTCB的地址即&pxCurrentTCB,在后续调用vTaskSwitchContext会用到r3,那么这里就先把r3压入主栈中,避免数据被破坏,留待后续使用。
[8]关中断。
[9]调用vTaskSwitchContext找到下一个任务,更新变量pxCurrentTCB的值。pxCurrentTCB的值更新为下一个任务的任务控制块地址。[7]中已经把&pxCurrentTCB保存起来了。通过对&pxCurrentTCB解引用,就能找到下一个任务控制块的地址了。
[10]开中断
[11]从sp(MSP)恢复r3,即把r3恢复成&pxCurrentTCB。后续就可以利用r3得到新的任务控制块了。
[12]得到变量pxCurrentTCB的值,存入r1
[13]得到新的任务控制块的第一个变量(pxTopOfStack)值。r0这是就是任务B的栈BS的栈顶指针。这时的栈压入的寄存器就像前面保存任务A的状态一样。
[14]弹出栈BS中保存的r4-r11,r14。
[15]如果是按照带浮点运算单元的栈帧保存的,就要弹出s16-s31。
[16]更新栈BS的栈指针给psp。这时栈内保存的s是任务B之前发生PendSV时硬件自动产生的栈帧。
[17]跳转到r14(LR),这时的r14实际是,任务B发生PendSV时保存的EXC_RETURN。这时所有的寄存器都更新为了任务B时的状态,栈指针也是任务B之前退出PendSV的状态。任务切换完成了!
接着Cortex-M就会认为psp保存的是中断后的栈帧,取出栈帧然后继续运行。而psp已经不是指向任务A进入时的栈帧,而是任务B的栈帧了。程序也开始在任务B中运行了。
总结下,PendSV中任务切换的过程其实就是:
1.产生PendSV中断,硬件自动保存栈帧到任务A的栈中
2.读取当前任务A的栈指针PSP,手动把一些寄存器压栈到当前任务栈。
3.把当前任务A栈顶指针保存到任务A的任务控制块中。
4.找到下一个任务B的任务控制块。(查找下一个优先级最高的就绪任务)
5.把任务B控制块的栈顶指针指向的数据弹出到寄存器中
6.更新PSP为任务B的栈顶指针。
7.跳出PendSV中断。
8.硬件自动弹出任务B栈中的栈帧。
0x03 总结
以上就是FreeRTOS最核心的部分了。如果理解了,也就发现这个过程其实很自然。切换过程就是保存当前任务的所有寄存器到当前栈,然后恢复下一个任务的所有寄存器。当然其中涉及到很多关于Cortex-M架构的相关知识,核心就是栈帧stack frame。因为中断栈帧的存在,其实有相当一部分的寄存器不用手动保存,硬件已经完成了那部分工作。另外还有关于两个栈指针PSP和MSP,以及EXC_RETURN的使用。
最后感谢左忠凯老师的《FreeRTOS源码详解与应用开发》以及相关视频。
此外还参考了《The Definitiv Guide to ARM Corte-M3 and Cortex-M4 Processors》。
很开心发现自己其实是有能力把任务切换过程看懂的。之前虽然也一直在读《The Definitiv Guide to ARM Corte-M3 and Cortex-M4 Processors》,但是却没有勇气去看FreeRTOS真正的核心部分。这次结合书本,以及试验过程才真正理解了这个过程,也算是因为没找到工作而给自己的一个小礼物吧。塞翁失马,焉知非福,也许这就是生活吧!
网友评论