一、概要
本人近段时间在分析和研读FreeRTOS内核的源码,分析到xPortPendSVHandler
任务切换函数的时候查了一下网上的资料,很多很全,但是没有一个非常深入、详细、易懂的教程,这里记录一下我自己的理解。有错误的地方还请指正,可以在评论区一起讨论。
学习内核的源码,能让人读懂、能理解的讲述才是最好的,本篇文章只详细介绍xPortPendSVHandler
这一个函数的机制和原理,选用的FreeRTOS移植的是ARM-Cortex-M4F
的内核,内容与M3基本一致,可以一同分析。要理解xPortPendSVHandler
的底层原理还需要用的其他的一些基础知识,这里都已罗列,但是不会细讲,需要有一定的ARM开发基础。
二、分析源码的前提
1.ARM内核寄存器的理解
- ARM内核中有上图所示这些寄存器。
- R0-R12都可以用来保存中间的计算结果。
- R13比较关键,R13是堆栈指针,MSP是主堆栈指针,PSP是进程堆栈指针,两个指针可以通过指令来切换使用,两个指针的值可以不一样,可以自己设置,了解这个即可。
- R14是链接寄存器LR,保存函数的返回地址。函数跳转硬件自动保存PC的下一条指令地址到LR
- R15是程序计数器PC,PC里面的当前值一般指向正在执行指令地址+4处,PC指向哪里,CPU就运行哪条代码,了解这个即可。
ARM内核中的寄存器的值就表示当前CPU运行的状态,我们称为“现场”,了解以上知识我们就可以进行下一步准备。
2.栈的理解
- 栈是一种数据结构,在MCU中,它是一段位于RAM的内存空间。我们只需要知道:栈按照先进后出的原则,栈向下增长(从栈顶指针处地址开始递减)就OK。
- push是入栈,将SP指针减一,再填入某个数据(注意32位MCU中指针操作+1都是当前地址+0x00000004)
- pop是出栈,去除某个数据存到某个寄存器,再将SP+1
- 栈一般是用来保存函数即将跳转时当前函数内的寄存器状态
3.分析pxPortInitialiseStack函数
为什么要了解这个函数?因为这个函数初始化了任务的堆栈,每个任务在被创建的时候都会通过该函数初始化堆栈,每个任务都有自己的堆栈,这个堆栈就称为任务运行时的“现场”,切换任务,当然就是切换现场,所以这个函数对于理解任务切换机制非常有用。(如果已自行了解该函数可以跳过此分析)
pxPortInitialiseStack
函数源码如下:(已将注释翻译成中文)
/*
* See header file for description.
*/
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 */
pxTopOfStack--;
*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK; /* PC */
pxTopOfStack--;
*pxTopOfStack = ( StackType_t ) prvTaskExitError; /* LR */
/* Save code space by skipping register initialisation. */
/* 跳过寄存器初始化以节省代码空间 */
pxTopOfStack -= 5; /* R12, R3, R2 and R1. */
*pxTopOfStack = ( StackType_t ) pvParameters; /* R0 */
/* A save method is being used that requires each task to maintain its
* own exec return value. */
/* 正在使用一种保存方法,该方法要求每个任务维护自己的exec返回值 ARM内核知识,LR保存值为0XFFFFFFFD表示返回之后使用PSP*/
pxTopOfStack--;
*pxTopOfStack = portINITIAL_EXC_RETURN;
pxTopOfStack -= 8; /* R11, R10, R9, R8, R7, R6, R5 and R4. */
return pxTopOfStack;
}
/*-----------------------------------------------------------*/
这里放VScode图片内容更醒目。
光看这一个函数可能看的很迷糊,不急,我们一步步分析。
(1)参数1:StackType_t * pxTopOfStack
- 首先分析函数的第一个传入参数
StackType_t * pxTopOfStack
,栈顶指针。分析他的调用关系,他是被传入的什么值,具体分析如下: - 在task.c文件第1006行(大概),被
prvInitialiseNewTask
函数调用,传入值为pxTopOfStack
pxTopOfStack
的赋值在前面,第857行,如图:
-
- 857行赋值这个指针使其等于要新创建任务的TCB结构体中栈数组首地址,这个首地址是什么?我们还要去了解这个数组是在哪里被分配的。
-
- 在task.c文件第801行,被
xTaskCreate
函数调用,他的第7个参数就是pxNewTCB
,如下图:
- 在task.c文件第801行,被
-
- 然后我们需要知道在哪里
pxNewTCB->pxStack
该值被分配,继续往前找,在第763行的这一块。如下图:
- 然后我们需要知道在哪里
-
pvPortMallocStack
就是分配该任务堆栈空间的函数,得到的首地址赋值给了pxStack
,然后紧接着在后面就把pxStack
赋值给了pxNewTCB->pxStack
,至此,我们找到了pxNewTCB->pxStack
的来源。
回到下面这张图:
- 第一句我们分析完了,通过
malloc
分配了指定栈大小的空间到该TCB结构体的pxStack
中,然后获取这个地址值给到pxTopOfStack
变量作为新创建任务的栈顶指针 - 第二句是使一个地址按8字节方式对齐,如果
malloc
申请的内存首地址不能被8字节整除,那就要对他进行偏移,舍弃一部分空间不使用,使这块内存首地址进行8字节对齐。至于为什么要进行字节对齐,那得自行去了解操作系统和ARM内核了,这里不细讲。 - 至此第一个参数
StackType_t * pxTopOfStack
就分析完了,总结:他表示这个新创建任务的栈顶指针。
(2)参数2:TaskFunction_t pxCode
- 很明显,他就是我们要执行的任务函数的函数入口指针。
- 我们观察他的作用,如下图:
- 可以看到通过注释我们知道他最终被保存进了PC寄存器,中间过程我们等会再分析。
- 前面讲过PC指向哪里,CPU就运行哪条代码,看到这里我们就可以猜测到任务切换时的操作就是通过这种方式实现的(确实是的),直接改变PC的值,使其等于某个任务的任务运行函数入口指针,后面的&上某个宏定义是有关ARM内核指令集的内容,这里不介绍。
(3)参数3:void * pvParameters
- 作用位置如下图:
void * pvParameters
最终被保存进R0寄存器,这里直接告诉大家答案,这个是为了给任务函数传递用户自定义参数用的,在ARM的指令集规范里面,R0-R3一般用来传递函数的参数。
(4)过程分析
- 参数分析完毕,现在进行过程分析。
/* 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 */
- 首先
栈顶指针--
,然后再填入数据portINITIAL_XPSR
,就是我上边介绍栈时的push
操作,(指针--
操作默认是减去4字节,后文不重复解释),假设栈大小还是100字节,栈顶指针0x20000100,此时新任务堆栈结构如下图:
- 下一步继续
push
pxTopOfStack--;
*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK; /* PC */
pxTopOfStack--;
*pxTopOfStack = ( StackType_t ) prvTaskExitError; /* LR */
- 栈:
- 后面的步骤就是连续
push
操作,这里就不逐步分析了,直接根据注释,给出该函数执行完成后的栈分配图。
- 根据函数内容和前面的分析,应该大家都能推出这张图了。(为什么空缺的都是0XA5?其实在前面我们通过
memset
将所有栈空间内存全部覆写为0XA5了,不过这对我们的分析不重要,可以跳过)。 - 实在还是不太理解中间的过程也没关系,先记着这张图,后面还会再提到,分析这个函数,只要知道最后这张图即可,最后这张图是核心!
(5)pxPortInitialiseStack调用过程
- 通过前面的分析大家也可以找到
pxPortInitialiseStack
函数是被xTaskCreate
函数调用的。而这个xTaskCreate
就是我们最常见的动态创建任务API了,层级调用关系如图:
- 通过层级调用关系我们就知道:我们每创建一个新任务都会在创建的时候为他分配好堆栈空间,并且初始化好前面提到的栈空间图的数据,堆栈的起始地址由
malloc
自动分配,大小由用户决定,在xTaskCreate
的时候需要指定堆栈大小,每个任务由自己的TCB
结构体,栈顶指针放在该结构体中。 - 假如我们在程序中创建了
Task1
、Task2
、Task3
这三个任务,初始化堆栈大小都是100字节,那么在MCU
的RAM
中,就会有以下三个块:(引用的前图,实际地址大家不用关系)
- 至此,
pxPortInitialiseStack
分析就完成了。 - 结论:
pxPortInitialiseStack
按照下图给每个创建的任务栈顶处填充了数据。
4.MSP和PSP的切换
任何中断和异常中都使用MSP
主栈顶指针,什么意思,就是在中断中的程序都使用MSP
里面保存的值作为当前SP
(R13
寄存器)的值。
在FreeRTOS中,从MSP
切换到PSP
使用的方法是:
-
1.先将中断返回特殊值
portINITIAL_EXC_RETURN(0xfffffffd)
保存至任务堆栈中。
-
2.再将任务控制块的栈顶指针
pxCurrentTCB
(因为在TCB结构体中,栈顶指针放在首位,所以结构体首地址就是栈顶指针)保存至PSP
中。
-
3.
pop
出栈,将特殊值portINITIAL_EXC_RETURN
从栈中弹出至R14(LR寄存器)
中,在SVC
中断中,如果返回地址是特殊值portINITIAL_EXC_RETURN
的话返回之后程序将使用PSP
指针(ARM内核规定),如下图:
SVC
中断中POP
指令如下图:
ARM内核的中断有规定,程序在进入中断之前使用哪个堆栈指针,那么程序退出中断时就会使用哪个堆栈指针,而FreeRTOS是使用中断来切换任务,所以从切换完第一次堆栈指针之后,所有任务都使用PSP
指针,而中断都自动使用MSP
指针,这就是FreeRTOS中使用的双堆栈机制,这个机制有什么用?后面分析会用到。
三、开始逐行分析xPortPendSVHandler
1.xPortPendSVHandler执行的前文
首先得知道一点:xPortPendSVHandler
是在滴答定时器中断里面被调用,被调用的情况是系统发生了任务调度,要从上一个任务切换到下一个要运行的目标任务。
我们假设以下的情景如下图:
后续任务1都会被认为是被切换的任务,任务2都是被切换后的目标任务。
2.xPortPendSVHandler源码(M4F内核版本)
源码如下:(注释已翻译)
__asm void xPortPendSVHandler( void )
{
extern uxCriticalNesting;
extern pxCurrentTCB;
extern vTaskSwitchContext;
/* *INDENT-OFF* */
PRESERVE8
mrs r0, psp
isb
/* Get the location of the current TCB. */
/* 获取当前TCB的位置 */
ldr r3, =pxCurrentTCB
ldr r2, [ r3 ]
/* Is the task using the FPU context? If so, push high vfp registers. */
/* 任务是否使用FPU上下文?如果是,则保存vfp寄存器 */
tst r14, #0x10
it eq
vstmdbeq r0!, {s16-s31}
/* Save the core registers. */
/* 保存核心寄存器. */
stmdb r0!, {r4-r11, r14}
/* Save the new top of stack into the first member of the TCB. */
/* 将新的栈顶保存到TCB的第一个成员中。 */
str r0, [ r2 ]
stmdb sp!, {r0, r3}
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
msr basepri, r0
dsb
isb
bl vTaskSwitchContext
mov r0, #0
msr basepri, r0
ldmia sp!, {r0, r3}
/* The first item in pxCurrentTCB is the task top of stack. */
/* pxCurrentTCB中的第一项是堆栈的任务顶部 */
ldr r1, [ r3 ]
ldr r0, [ r1 ]
/* Pop the core registers. */
/* 弹出核心寄存器 */
ldmia r0!, {r4-r11, r14}
/* Is the task using the FPU context? If so, pop the high vfp registers
* too. */
/* 任务是否使用FPU上下文?如果是,则弹出vfp寄存器 */
tst r14, #0x10
it eq
vldmiaeq r0!, {s16-s31}
msr psp, r0
isb
#ifdef WORKAROUND_PMU_CM001 /* XMC4000 specific errata */
#if WORKAROUND_PMU_CM001 == 1
push { r14 }
pop { pc }
nop
#endif
#endif
bx r14
/* *INDENT-ON* */
}
3.第一部分——保存现场
PRESERVE8
表示从C代码进入汇编代码的世界时,要保证汇编部分的编码在调用c接口时栈是8字节对齐的。
*------------------------------------------------------
mrs r0, psp
mrs
指令可以操作特殊功能寄存器,他的功能与ldr
指令一致,只是权限更高,可以访问特殊功能寄存器。这里用它访问psp
寄存器,把psp
值读出并存放到r0
。通过阅读源码可知,此时psp
的值就是pxCurrentTCB
当前运行任务的栈顶指针值,如下图所示位置被修改:
(SVC
这里不进行分析,但这里补充一点SVC中断
的知识:SVC中断
只在任务开始调度时运行一次,在SVC
中psp
被赋值为当前任务的栈顶指针是为了开始第一个任务的运行,后续SVC
不会再次被触发,所以psp
指针其实每次就是指向了当前任务的栈顶)。
xPortPendSVHandler
在前半部分很明显是为了保存现场, 这条指令很好的体现了双堆栈指针的好处,在这里就可以直接取出psp
的值作为任务1的栈顶指针,而不再需要逐步往前推SP
寄存器的值才能得到前一个任务的栈顶指针。因为此时是中断状态,CPU
是使用MSP
指针,不会因为中断而在任务的堆栈上保存相关信息。
*------------------------------------------------------
isb
指令同步隔离。最严格:它会清洗流水线,以保证所有它前面的指令都执
行完毕之后,才执行它后面的指令。(ARM内核知识,不需要理解,有兴趣的可以去了解ARM-Cortex-M3内核取指令时的流水线机制)
*------------------------------------------------------
ldr r3, =pxCurrentTCB
ldr
指令,没啥好讲的,就是把后一个值读出并存放到,
前面的寄存器,但这里是用了特殊的用法,使用的是伪指令,功能就是将任务1的TCB
结构体指针的地址写入r3
(因为ldr
只能存放符合规范的32位立即数或值,TCB的地址值可能是不合规范的32位值,这里使用伪指令就能这样操作)。
*------------------------------------------------------
ldr r2, [ r3 ]
读出r3
指向的地址的值并存放r2
(等价r2 = (*r3)
),得到任务1TCB
结构体的首地址并存放到r2
。
*------------------------------------------------------
tst r14, #0x10
it eq
vstmdbeq r0!, {s16-s31}
这三条是保存当前浮点寄存器的值至浮点寄存器(Cortex-M4F
内核才有的,M3内核代码中没有该三句),M3内核可以不用关心,这三句不重要。
*------------------------------------------------------
stmdb r0!, {r4-r11, r14}
这条指令就是比较核心的push
入栈操作了,以r0
为基地址。开始把r4
寄存器中地址指向的内容读出并放入r0
寄存器中地址指向的内容,然后r0
向下增长一个地址(4字节),然后再把r5
所指内容放入r0
…依此类推。
r0
在这里是psp
的值,也就是保存当前CPU内r4-r11
还有r14(LR)
的值到栈中。目前的栈如下图:(注意这里r0
只是引用了psp
的值而未改变psp
,所以只是栈变化,psp
未变化)
*------------------------------------------------------
str r0, [ r2 ]
str
指令就是ldr
指令的反义,他将r0
的值写入r2
所指的地址。从上图我们可以指定r0
目前的值就是任务1当前栈空间的栈顶指针,而r2
所指位置就是任务1的TCB结构体里面存储的栈顶指针的值。这一步就是将保存的现场(现场就是CPU各寄存器的值)存储覆写到自己的TCB结构体中保存的栈顶指针值,更新栈顶指针。
我们可以看到,我们在这里只保存了部分寄存器,还有一部分寄存器怎么没有保存?比如r0,r1,r2
这些。这是因为ARM内核在程序进入PendSV中断
中硬件会自动保存r0、r1、r2、r3、PSR、PC、LR
寄存器,我们能看到的保存都是软件保存,他们存放的位置就是位于上图浮点寄存器空间的上方。
至此,CPU所有寄存器都已入栈,保存了现场之后的栈顶指针也都更新到了当前任务(这里是任务1)的TCB中。
我们再看看前面画的这张图:
是不是在PendSV中断
进入到这一步时,所有的任务的栈空间都回到了这种状态?(只是红色箭头指的不一定是最上面那块内存,可能因为任务运行导致使用了栈空间而下移)是不是跟前面的串通了?
*------------------------------------------------------
4.第二部分——选出最高优先级任务,更新pxCurrentTCB
stmdb sp!, {r0, r3}
这条指令会与后面的指令ldmia sp!, {r0, r3}
对应,一同分析。
先明确,这里的sp
是在操作msp
所指位置,因为这是在中断中。所以这条指令不会干涉到psp
的入栈和出栈。这条指令前面已经介绍过,就是依次入栈。这是为了后续要进入函数跳转,也需要保存现场而做的准备,将r0、r3
入栈,等到函数执行完成,在r0、r3
出栈,恢复到CPU内。
*------------------------------------------------------
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
mov
指令是数据传输指令,这里用法很简单,就是把后面这个宏定义的立即数存放到r0
寄存器。configMAX_SYSCALL_INTERRUPT_PRIORITY
的值如下图:
定义了内核可屏蔽的最高优先级。为后续关中断做准备。
*------------------------------------------------------
msr basepri, r0
使用msr
指令操作特权寄存器basepri
,目的是屏蔽某一个优先级及其以下优先级的中断(关中断),防止后续找出最高优先级的过程中被其它中断打断。
*------------------------------------------------------
dsb
isb
数据和指令同步
*------------------------------------------------------
bl vTaskSwitchContext
bl
是程序跳转指令,跳转到一个地址去执行程序并将此时的返回地址保存至LR
寄存器。vTaskSwitchContext
这是个C函数,他的作用是找出当前就绪态链表中最高优先级的任务,并将当前任务控制块pxCurrentTCB
的值更新为这个任务的TCB。(就是我们说的任务切换了,当前任务从任务1切换至任务2)。
我们这里只关心任务如何切换,这里有关的内容就是pxCurrentTCB
的值被更新,至于如何找到最高优先级的任务,可以看最后的补充内容。
*------------------------------------------------------
mov r0, #0
msr basepri, r0
这两句一起讲,与前面的关中断对应,这里就是开中断,basepri
写入0就是不屏蔽任何中断。
ldmia sp!, {r0, r3}
也在前面已经被分析过了。
*------------------------------------------------------
5.第三部分——恢复现场
ldr r1, [ r3 ]
这里的r3
可以从前面的程序知道,保存的是pxCurrentTCB
的地址,访问这个地址就可以得到任务2的TCB首地址(这里pxCurrentTCB
已经被更新为任务2了)。存放在r1
寄存器中。
*------------------------------------------------------
ldr r0, [ r1 ]
读出r1
寄存器地址所指数据存放到r0
,就是读出任务2的栈顶指针。因为在TCB中,TCB结构体首地址存放的数据是从栈顶指针开始。
*------------------------------------------------------
ldmia r0!, {r4-r11, r14}
开始出栈,与stmdb r0!, {r4-r11, r14}
是对应的,不用过多分析,就是将任务2的栈空间按照顺序,将内容逐个移至CPU寄存器中,实现恢复现场。
任务2在被调度时,也会像刚刚任务1那样,先被保存了现场,因此任务2在被恢复现场时可以参考上面的栈空间图片,他们都是一样的。
如果任务2是第一次被恢复,那么也需要一个栈,这个栈就联系起来了我们为什么要去分析pxPortInitialiseStack
函数了,该函数在任务2还没有被保存现场时就构造了一个现场,函数列出了栈中所有寄存器的值。
*------------------------------------------------------
tst r14, #0x10
it eq
vldmiaeq r0!, {s16-s31}
恢复浮点寄存器的值
*------------------------------------------------------
msr psp, r0
这一步也很关键,我们知道前面r0
在恢复完所有的寄存器之后,就回到了原来的栈顶。因为每个任务的栈空间都是独立分配的,他们不一定连续,所以就要不断修改psp
的值,因为psp
是一定要指向当前程序的栈顶的,所以在恢复完现场之后,栈顶也要恢复。
*------------------------------------------------------
isb
指令同步
*------------------------------------------------------
6.第四部分——任务栈恢复分析
bx r14
bx跳转指令,目标地址处的指令既可以是ARM 指令,也可以是Thumb指令。
注意这里是跳转到r14
,我们要分析此时CPU的r14
存放的是什么内容。分两种情况。
-
情况一:任务2不是第一次被调度
这种情况下,是属于任务2在运行过程中被SysTick中断
打断,那么在打断的同时硬件就保存了程序要运行的下一条指令作为返回地址存放至r14
,此时在执行这条指令时程序就原封不动的回到任务2被打断之前的地方继续执行任务。 -
情况2:任务2是第一次被调度
这种情况下,任务2的栈是被程序构造的,他的r14
寄存器存放的是一个特殊值portINITIAL_EXC_RETURN
,如下图:
这里为什么把这个位置称为(LR(特殊))呢?我们进行分析。任务被创建时,就构造了这么一个现场,在第一次被恢复时,是使用ldmia r0!, {r4-r11, r14}
这条指令,没错,在(R11)上方这个位置,被我们软件恢复至r14(LR)
寄存器。
又因为任务2是第一次被调用,不会有硬件帮我们保存上半部分那些寄存器,所以FreeRTOS用软件的方式保存了那些寄存器,并设置LR
为特殊值0xfffffffd
,使得程序在bx r14
时,被当做一个中断返回处理,返回之后程序使用psp
的值作为sp
栈顶指针,并自动将上半部分的所有寄存器自动出栈恢复至CPU内。
我们知道,pc
寄存器的值就是程序要执行的指令的地方,上边部分的值被恢复,上图pc
的值同样被恢复了,而pc
的值被设置为任务2的回调函数入口地址,所以从下一个指令开始,程序就会运行任务2了。
至此,任务2切换完成。
*------------------------------------------------------
至此,xPortPendSVHandler
分析完成。
四、补充内容
vTaskSwitchContext
是找出就绪态链表最高优先级的函数,这个函数就是FreeRTOS的调度算法,最核心内容。
他的内部实现是通过taskSELECT_HIGHEST_PRIORITY_TASK
来实现
宏定义主体:
箭头指的就是他的调度算法。
#define portGET_HIGHEST_PRIORITY( uxTopPriority, uxReadyPriorities ) uxTopPriority = ( 31UL - ( uint32_t ) __clz( ( uxReadyPriorities ) ) )
这里不带大家分析代码了,直接说过程和结论。
- 每个任务在创建时都会根据自己的优先级将一个变量对应的那一位(比如自己的优先级为5,就会把一个32位变量的第5位置1,其他位不变)置1,这个变量是个全局变量。
__clz
指令可以计算出某个32位变量前导0的个数,拿31减去这个数,(比如当前最高优先级任务时20,那么之前的全局变量前导0个数就是11,31-11就是20)就可以得到当前就绪态链表最高优先级是位于哪个优先级链表中。(FreeRTOS每个优先级都会对应一个链表)- 找到位于哪个链表之后,就可以直接去该链表中取出该任务的TCB,更新至
pxCurrentTCB
。
当然还有另一种情况,如果我配置FreeRTOS可支持最大优先级超过32怎么办?比如设置成100,如果一个任务优先级为40,这个时候自然是不能使用这种方法了,因为__clz
指令没办法获取64位变量前导零个数。这个时候就只能使用传统方法了,一个个链表找,从高优先级开始遍历,直至链表不为空。