前言
在学习FreeRTOS中,无论是链表学习还是任务定义创建还是就绪列表都很好理解,无非是一些结构体的定义,不会像学习任务调度时那样要对CPU的工作模式及模式切换有一定的了解。
以stm32为例,FreeRTOS在实现任务调度的时候利用到了ARM内核的异常处理,而ARM的异常处理里有两个概念必须理解,才能理解FreeRTOS的调度实现,这两个概念分别是MSP与PSP和Thread模式和Handler模式。
这里笔者会在讲解代码的时候来讲它们,而不是提前给出它们的概念,因为懂的人不会看,不懂的人看了也不理解,所以碰到它们的时候再讲,或许更有用。
笔者在看代码的时候,真正让自己不理解的也就是用汇编开始写的那段代码,也即是开启第一个任务函数(__asm void prvStartFirstTask( void )),直接看代码:
__asm void prvStartFirstTask( void )
{
PRESERVE8
/* 在Cortex-M中,0xE000ED08是SCB_VTOR这个寄存器的地址,
里面存放的是向量表的起始地址,即MSP的地址 */
ldr r0, =0xE000ED08
ldr r0, [r0]
ldr r0, [r0]
/* 设置主堆栈指针msp的值 */
msr msp, r0
/* 使能全局中断 */
cpsie i
cpsie f
dsb
isb
/* 调用SVC去启动第一个任务 */
svc 0
nop
nop
}
注释的前提是理解ARM的启动流程,知道ARM是如何创建向量表,以及主堆栈(MSP)的地址放在哪里。这里碰到MSP了,讲一下。
栈指针(MSP和PSP)
MSP:main stack pointer。主堆栈指针,如果只是玩单片机,你可以就叫它堆栈指针,也就是你的程序的栈指针,用来存放局部变量等的内存空间,它被分配在RAM中。为什么要说一个只玩单片机的前提呢?当然是因为FreeRTOS操作系统与裸跑有区别啊,FreeRTOS在执行任务的时候,任务的栈不再是MSP,而是自己的堆栈,这也是在讲任务调度前的任务创建中讲到过的,每个任务有一个自己的栈空间,这个任务自己的栈空间就是PSP(Process stack pointer),也即进程栈指针,你也可以叫它任务栈指针。
PSP:Process stack pointer。任务栈指针。
工作模式和特权级别
在语句svc 0开始执行前,程序都是运行在MSP下的,有人会问原因。如果要说明原因,就必须从ARM的工作模式说起。其实这里说的工作模式与我们学习的ARM工作模式还有点区别,只是在KEIL里面,我们学习的ARM工作模式对应的是Privilege,而Mode对应的则是笔者现在要讲的工作模式。
那这里先做个区分,我们学习的ARM工作模式,其实应该叫做工作级别,因为ARM的那8个模式其实还广泛的分为了特权模式和用户模式,如果叫模式就很容易与现在说的搞混,所以直接说级别。
这里要说的工作模式,只有两种,一种是Thread模式,一种是Handler模式。这两种模式,Handler就是在异常发生时CPU的工作模式,而Thread则是正常工作时候的模式。
工作模式与MSP和PSP的关系
所谓的关系,也就是工作在哪个模式下对应哪个栈。
工作在Thread模式下的时候可以是MSP也可以是PSP,为什么这么样说呢?以单片机为例,在不发生异常的时候,肯定是Thread模式的,那么栈是哪个栈呢?当然是MSP了,因为单片机只用到MSP。那么什么时候是Thread模式下用PSP呢,也很简单,就是我们用到了FreeRTOS的时候,任务已经被调用到,这时候工作在Thread下,而且因为任务只能用到自己的栈空间,而这个栈空间只能用PSP。
工作在Handler模式下,只能是使用MSP,这是ARM-Cortex-m系列的规定,可以查看CM3的参考手册。也可以对应着任务来理解吧,因为任务必须在PSP下,也就是说用户级别的任务用进程栈,而异常处理显然是内核级别的任务,当然要与用户区分开来。这也是操作系统的使命,让内核和用户区分开。
代码分析
那么讲了上面那些有啥用呢?再看代码。
可以看到所谓的开始第一个任务,其实根本只是在设置主堆栈的指针,去出厂就设置好了的寄存器SCB_VTOR(0xE000ED08)中去获取MSP的值,然后将这个值写入MSP中。
接下来就执行SVC 0语句,进入到SVC_Handler异常处理函数中。其实也就是产生一个SVC的异常。
其实在稍微想一个就会知道,这是不是就是说真正的去开始第一个任务的处理就是在SVC异常中呢?如果你这样想了就说明真的在思考了。
__asm void vPortSVCHandler( void )
{
extern pxCurrentTCB;
PRESERVE8
ldr r3, =pxCurrentTCB /* 加载pxCurrentTCB的地址到r3 */
ldr r1, [r3] /* 加载pxCurrentTCB到r1 */
ldr r0, [r1] /* 加载pxCurrentTCB指向的值到r0,目前r0的值等于第一个任务堆栈的栈顶 */
ldmia r0!, {r4-r11} /* 以r0为基地址,将栈里面的内容加载到r4~r11寄存器,同时r0会递增 */
msr psp, r0 /* 将r0的值,即任务的栈指针更新到psp */
isb
mov r0, #0 /* 设置r0的值为0 */
msr basepri, r0 /* 设置basepri寄存器的值为0,即所有的中断都没有被屏蔽 */
orr r14, #0xd /* 当从SVC中断服务退出前,通过向r14寄存器最后4位按位或上0x0D,
使得硬件在退出时使用进程堆栈指针PSP完成出栈操作并返回后进入线程模式、返回Thumb状态 */
bx r14 /* 异常返回,这个时候栈中的剩下内容将会自动加载到CPU寄存器:
xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)
同时PSP的值也将更新,即指向任务栈的栈顶 */
}
可以看到前面的三句(省略掉字节对齐和外部引用)是在得到当前任务控制块的地址,得到它的地址的目的,当然就是得到任务的信息,因为TCB里含有所有任务该有的内容,比如任务栈栈顶,任务的执行体(函数)入口地址,任务的名字和任务栈的起始地址。
第四句,就是将任务栈里需要手动加载到CPU寄存器的那些内容写入。写入后,r0寄存器刚好指向需要自动写入CPU寄存器的位置,而这个位置的地址是在后面一句看的出来写入了PSP中。这又是一个新知识点。先记住这里,在程序的最后一句会讲到。
接下来将r0清零,将所有中断打开。
关键的点来了,将链接寄存器r14的最后四位写0xd,这是在干什么?这是在做跳出异常的准备。在用keil仿真的时候,进入异常处理后,会发现r14在进入到异常以后就变成了一个奇怪的值,0xFFFFFFFx,这个值是ARM的有意为之,只要见到这个值,就表示进入到了异常处理里面了。而低四位表示的就是进入到异常以后,CPU应该处于的对应状态,X的 bit0为 1表示返回 thumb状态,bit1 和 bit2 分别表示返回后 sp 用 msp 还是 psp、以及返回到特权模式还是用户模式。0xd表示的就是sp使用psp,从异常返回的时候返回到thumb状态。
到了最后一句bx r14,此时r14为0xFFFFFFFD,从异常返回,此时硬件会将栈中的剩余内容自动加载到CPU的寄存器中,别的先不管,PC这时候是任务的入口地址,LR这时候是什么值其实也不怎么重要,因为FreeRTOS中任务执行是不会返回的。但是这个值也是设置好的,只不过不会到达这里,也就是任务栈的初始化函数地址,这个函数是个空函数,如下:
/*
*************************************************************************
* 任务栈初始化函数
*************************************************************************
*/
static void prvTaskExitError( void )
{
/* 函数停止在这里 */
for(;;);
}
这个函数名就是在初始化任务栈的时候赋值给LR寄存器的:
StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxCode, void *pvParameters )
{
/* 异常发生时,自动加载到CPU寄存器的内容 */
pxTopOfStack--;
*pxTopOfStack = portINITIAL_XPSR; /* xPSR的bit24必须置1 */
pxTopOfStack--;
*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK; /* PC,即任务入口函数 */
pxTopOfStack--;
*pxTopOfStack = ( StackType_t ) prvTaskExitError; /* LR,函数返回地址 */
pxTopOfStack -= 5; /* R12, R3, R2 and R1 默认初始化为0 */
*pxTopOfStack = ( StackType_t ) pvParameters; /* R0,任务形参 */
/* 异常发生时,手动加载到CPU寄存器的内容 */
pxTopOfStack -= 8; /* R11, R10, R9, R8, R7, R6, R5 and R4默认初始化为0 */
/* 返回栈顶指针,此时pxTopOfStack指向空闲栈 */
return pxTopOfStack;
}
结语
以上执行完了就到了任务执行体中,当然在任务执行体中会有一个切换的异常唤醒,任务的切换就是在那个异常中执行的,内容与本文无关。本文的目的就是让与笔者有同样困惑于启动第一个任务的执行过程的朋友,去理解FreeRTOS是如何根据ARM内核的工作特性去完成启动一个已经初始化好的任务的,重点就是关注两个任务栈的在什么工作状态下的切换,以及关注出入栈的寄存器的值,因为寄存器的值直接影响到各个函数之间跳转的理解。
当然最后再提一句,如果还没有理解ARM内核的工作流程,或许依然无法理解本文,所以建议还没有学习ARM体系结构的朋友再去理解理解ARM的体系结构。
本文的资料参考的是野火的《FreeRTOS 战 内核实现与应用开发实战》,代码也是参考的野火的FreeRTOS参考例程。