源码请在https://github.com/ifreecoding/MbedRtos.git下载
第1节 两个固定任务之间的切换
程序的执行只与指令和数据相关,指令是不可修改的,编译后就确定了,能改变的只有数据,但指令需要对数据进行判断,走不同的指令分支,因此,如果我们需要控制程序的执行过程,不但需要编写出指令,还需要提供可方便使用的数据,操作系统任务切换的过程就是指令备份、恢复数据的过程。
通过前面的介绍,我们知道程序当前指令执行的结果只与R0~R15、CPSR这17个寄存器有关,只要我们能控制这17个寄存器,那么我们就可以控制程序的执行流程,这是实现任务切换的基础。
从C语言的角度来看,任务就是函数,只不过是在操作系统里,一个任务可以切换到其它任务,其实也就是一个函数可以切换到其它函数。当切换发生时,将正在执行的函数1的R0~R15、CPSR这17个寄存器临时保存起来,然后将希望执行的函数2的上次保存的数值恢复到R0~R15、CPSR这17个寄存器,这样芯片就从函数1切换到函数2运行了。当希望从函数2切换到函数1时,再将函数2的17个寄存器保存起来,恢复函数1的17个寄存器,芯片就又继续运行函数1了。这样便在函数1运行的中间插入了函数2,这就是任务切换,也就是所谓的“上下文切换”,函数1或函数2所在的最上层父函数调用的一系列函数就组成了任务,任务是从最上层父函数开始运行的。
这种切换也可以在多个任务之间进行,至于什么时候切换,怎么控制切换,这就是操作系统要做的事情了。
下面我们将遵循着这一设计思路来编写一个最简单的切换过程——2个函数之间不停的互相切换,来验证任务切换过程中寄存器备份、恢复原理的正确性。
为了能看出任务切换的效果,我们设计2个函数TEST_TestTask1和TEST_TestTask2,这两个函数都是死循环,反复执行“打印消息—>延迟”的过程,我们可以通过打印信息来确认是哪个函数在执行,伪码如下:
TEST_TestTask1: | TEST_TestTask2: |
while(1) { 打印“Task1 is running!”; 延迟时间1秒; } | while(1) { 打印“Task2 is running!”; 延迟时间2秒; } |
如果没有函数切换功能,那么这样的函数只要一开始执行,它们就会一直死循环执行下去,不会给其它函数执行的机会,我们就只能看到只有一个函数在循环打印消息。如果能够按照上面是所讲述的切换原理发生函数切换,那么我们就应该能看到的是这2个函数是在循环交替打印。
现在我们需要一个函数,它具有备份、恢复这17个寄存器的功能,在TEST_TestTask1和TEST_TestTask2需要切换时就调用它,完成上下文切换。我们将这个函数命名为WLX_TaskSwitch,我们将WLX_TaskSwitch函数加入到TEST_TestTask1和TEST_TestTask2里:
TEST_TestTask1: | TEST_TestTask2: |
while(1) { 打印“Task1 is running!”; 延迟时间1秒; WLX_TaskSwitch(); } | while(1) { 打印“Task2 is running!”; 延迟时间2秒; WLX_TaskSwitch(); } |
我们将WLX_TaskSwitch函数设计为一个C函数,它仅对一些全局变量赋值,这些全局变量用来指明切换前函数的相关信息和切换后函数的相关信息。至于寄存器组备份、恢复的具体过程,由于是涉及到操作寄存器,因此只能使用汇编语言编写,将这个过程封装到由汇编语言编写的WLX_ContextSwitch函数里面来实现。
我们将使用C语言和汇编语言编写操作系统。C语言作为高级语言具有较好的可移植性,并且控制硬件方便,在嵌入式领域有极广泛的应用,但无法直接控制芯片的寄存器。汇编语言是与芯片内部硬件息息相关的,可控制寄存器,但编码困难,可移植性差。因此,本手册本着尽可能使用C语言的原则,在C语言无法实现或实现成本太大的情况下才使用汇编语言。
现在我们先来看看WLX_TaskSwitch函数,最左侧的5位数字是代码在源代码文件里的行号。Wanlix和Mindows的全部代码都可以从http://blog.sina.com.cn/ifreecoding网站免费下载,也可在网站内部的论坛上讨论。
00060 void WLX_TaskSwitch(void)
00061 {
00062 if(1 == guiCurTask)
00063 {
00064
00065 gpuiCurTaskSpAddr = &guiTask1CurSp;
00066
00067
00068 guiNextTaskSp = guiTask2CurSp;
00069
00070
00071 guiCurTask = 2;
00072 }
00073 else //if(2 == guiCurTask)
00074 {
00075 gpuiCurTaskSpAddr = &guiTask2CurSp;
00076
00077 guiNextTaskSp = guiTask1CurSp;
00078
00079 guiCurTask = 1;
00080 }
00081
00082
00083 WLX_ContextSwitch();
00084 }
在这个函数里,我们用到了guiCurTask、guiTask1CurSp、guiTask2CurSp、gpuiCurTaskSpAddr、guiNextTaskSp这5个全局变量。guiCurTask用来指示当前运行的任务,在1和2之间不断变化。guiTask1CurSp保存的是TEST_TestTask1函数的寄存器组存储的内存地址,uiTask2CurSp保存的是TEST_TestTask2函数的寄存器组存储的内存地址,寄存器组备份、恢复时用的就是这两个全局变量指向的内存空间。gpuiCurTaskSpAddr用来存放guiTask1CurSp或guiTask2CurSp的地址,需要备份寄存器组的任务将指向它的寄存器组内存空间的变量的地址放入gpuiCurTaskSpAddr全局变量。guiNextTaskSp存放的是guiTask1CurSp或guiTask2CurSp,需要恢复寄存器组的任务将它的寄存器组内存空间地址放入guiNextTaskSp全局变量。这样,MDS_ContextSwitch函数在寄存器组备份、恢复时就可以通过gpuiCurTaskSpAddr和guiNextTaskSp分别找到备份和恢复寄存器组的内存空间了。
下面详细解释一下WLX_TaskSwitch函数:
00062行,对任务进行判断,如果当前运行的是任务1则进入此分支。
00065行,运行到此行,说明当前运行的是任务1,需要备份任务1的寄存器组数据,将任务1的全局变量guiTask1CurSp的地址存入全局变量gpuiCurTaskSpAddr中,准备供MDS_ContextSwitch函数使用。
00068行,需要还原任务2的寄存器组数据,将任务2的全局变量guiTask2CurSp保存到全局变量guiNextTaskSp中,准备供WLX_ContextSwitch函数使用。
00071行,准备从任务1切换到任务2,将保存当前运行任务ID的全局变量guiCurTask更新为将要运行的任务2。
00073~00080行与00062~00072行功能类似,不通的是从任务2切换到任务1的过程。
00083行,任务切换前的准备工作已经完成,调用WLX_ContextSwitch函数,开始寄存器组备份、恢复。
在介绍WLX_ContextSwitch函数之前,我们先设计一下保存寄存器组的内存结构,如图13所示:
寄存器组中每个写着寄存器名字的位置用来保存对应的寄存器,可以保存R0~R14和CPSR寄存器,但没有为R15进行备份,这是因为Wanlix的切换过程是由函数主动调用WLX_ContextSwitch函数实现的,是写死在代码里的,在编译的时候编译器就会安排代码,在任务切换前将R15自动保存在R14中,这样我们只需要备份R14就足够了。
这个寄存器组的位置存放在函数的栈中,当备份时,就从当前函数的SP栈指针指向的地址向栈增长的方向依次将寄存器存入,并更新SP栈指针,使之指向寄存器组中的“CPSR”,这也是一个压栈的过程,只不过是借用了函数的栈空间。当恢复时,从SP栈指针指向的寄存器组中依次恢复寄存器,并将SP栈指针恢复到函数切换前的所在位置,完成任务上下文切换。这时已经恢复的寄存器组所在的内存数据变为无效数据,当前函数运行时可能会压栈覆盖掉此空间的数据。
在这里还需要说明一下系统栈和任务栈的概念。软件在刚启动时都是运行在没有操作系统的环境下,这时候所有函数都会使用同一个栈(ARM7中中断模式除外),这个栈就叫做系统栈。启动操作系统后,程序就会以任务为功能单元运行,在建立任务时需要为每个任务分配一个栈供任务运行时使用,这个栈就称之为任务栈。软件进入操作系统后就不使用系统栈了,完全使用任务栈。由于操作系统不会重新返回到非操作系统状态下,因此系统栈中保存的数据也没有用处了,用户可以将系统栈的内存空间拿来另作它用。
有了前面的铺垫,我们来看看操作系统内核最核心的函数WLX_ContextSwitch:
00012 .func WLX_ContextSwitch
00013 WLX_ContextSwitch:
00014
00015 @保存当前任务的堆栈信息
00016 STMDB R13, {R0-R14}
00017 SUB R13, R13, #0x3C
00018 MRS R0, CPSR
00019 STMDB R13!, {R0}
00020
00021 @保存当前任务的指针值
00022 LDR R0, =gpuiCurTaskSpAddr
00023 LDR R1, [R0]
00024 CMP R1, #0
00025 BEQ GETNEXTTASKSP
00026 STR R13, [R1]
00027
00028 GETNEXTTASKSP:
00029 @获取将要运行任务的堆栈信息并运行新任务
00030 LDR R0, =guiNextTaskSp
00031 LDR R13, [R0]
00032 LDMIA R13!, {R0}
00033 MSR CPSR, R0
00034 LDMIA R13, {R0-R14}
00035 BX R14
00036
00037 .endfunc
00012行,定义WLX_ContextSwitch函数。
00013行,这是一条汇编伪指令,只是一个标号,代表WLX_ContextSwitch函数的起始地址,并不生成可执行代码。
00015行,在GNU环境的汇编语言里,“@”符号代表注释符,编译时其后的所有字符都被注释掉,不生成任何代码,其存在只为程序提供说明性帮助。
00016行,这是该函数的第一条可执行语句,它将当前任务的R0-R14寄存器保存到寄存器组中,寄存器组的地址由SP寄存器指定。
00017行,更新数据入栈后的SP栈指针。
00018行,将当前任务的CPSR寄存器保存到R0中。
00019行,将R0寄存器存入寄存器组,也就是把CPSR寄存器的内容存入寄存器组,并更新SP寄存器。00016~00019行所使用的汇编指令并不会改变CPSR寄存器的内容,因此00019行这条指令保存的R0就是当前任务进入WLX_ContextSwitch函数前的CPSR寄存器的数值。
00022行,获取全局变量gpuiCurTaskSpAddr的地址。
00023行,获取全局变量gpuiCurTaskSpAddr的内容,也就是存放当前任务的寄存器组的全局变量guiTaskXCurSp(当前任务为1则guiTaskXCurSp为guiTask1CurSp,当前任务为2则则guiTaskXCurSp为guiTask2CurSp)。
00024行,将gpuiCurTaskSpAddr全局变量的内容与0做比较。在非操作系统状态下全局变量gpuiCurTaskSpAddr为0,无需保存寄存器组数据。
00025行,如果gpuiCurTaskSpAddr全局变量为0,则跳转到GETNEXTTASKSP标志所在地址,准备进入操作系统状态,但还没有任务在运行,因此,不需要保存非操作系统状态下的SP栈指针。如果不为0则代表已经进入操作系统状态,不执行本行,执行00026行。
00026行,将当前任务的SP栈指针存入当前任务的guiTaskXCurSp全局变量中,下次运行时可借此找到任务的栈指针,并根据栈指针从寄存器组中恢复出寄存器的数值。
00030行,获取全局变量guiNextTaskSp的地址。
00031行,获取全局变量guiNextTaskSp的内容,也就是存放将要运行任务的寄存器组的地址,将寄存器组的地址存入SP中。
00032行,根据SP从寄存器组中恢复将要运行任务的CPSR寄存器,恢复到R0中。
00033行,将R0保存到CPSR中,也就是恢复了将要运行任务的CPSR寄存器。
00034行,恢复R0-R14寄存器,其中包含了SP寄存器,因此不再需要额外更新SP寄存器。
00035行,跳转到将要运行的任务。至此,已经完成了2个任务的寄存器出入栈工作,芯片当前工作的寄存器已经从切换前运行的任务全部换成了切换后将要运行的任务,寄存器数据已经全部处理完了,只要能将PC指针正确跳到将要运行任务上次切出去那一时刻的位置就可以了。此时LR寄存器中保存的就是将要运行任务的上次切出去的地址,跳转到LR,完成2个任务切换的最后一步。
上面实现了任务的切换过程,但是还有一些事情需要解决,那就是测试函数TEST_TestTask1和 TEST_TestTask2第一次运行时,栈中的寄存器组是空的,无法进行寄存器组恢复,也就无法切换了。因此,我们需要一个初始化函数,来为第一次运行的任务初始化它的寄存器组栈空间。这个任务初始化函数是WLX_TaskInit,它只需要在本任务的栈内为寄存器组赋初值就可以了,至于每个寄存器应该赋什么初值,我们可以分析一下。
由于任务是第一次运行,所有一切都是空的,函数不会从R0~R12寄存器读数据使用,因此可以将这些寄存器全部置0。SP是栈指针,所以需要将任务的栈顶赋给SP。LR是返回地址,需要通过跳转到LR去第一次执行这个函数,而函数名就是函数的第一条指令所在的地址,函数名是指针,因此,将函数名存入存入LR中。
具体实现过程来看下面来看代码:
00020 void WLX_TaskInit(U8 ucTask, VFUNC vfFuncPointer, U32* puiTaskStack)
00021 {
00022 U32* puiSp;
00023
00024
00025 puiSp = puiTaskStack;
00026
00027 *(--puiSp) = (U32)vfFuncPointer;
00028 *(--puiSp) = (U32)puiTaskStack;
00029 *(--puiSp) = 0;
00030 *(--puiSp) = 0;
00031 *(--puiSp) = 0;
00032 *(--puiSp) = 0;
00033 *(--puiSp) = 0;
00034 *(--puiSp) = 0;
00035 *(--puiSp) = 0;
00036 *(--puiSp) = 0;
00037 *(--puiSp) = 0;
00038 *(--puiSp) = 0;
00039 *(--puiSp) = 0;
00040 *(--puiSp) = 0;
00041 *(--puiSp) = 0;
00042 *(--puiSp) = MODE_USR;
00043
00044
00045 if(1 == ucTask)
00046 {
00047 guiTask1CurSp = (U32)puiSp;
00048 }
00049 else //if(2 == ucTask)
00050 {
00051 guiTask2CurSp = (U32)puiSp;
00052 }
00053 }
00020行,函数定义,ucTask入口参数确定需要初始化的函数;vfFuncPointer入口参数是需要初始化任务的函数;puiTaskStack入口参数是这个任务所使用的任务栈指针,需要是栈顶满栈指针。
00025行,将puiSp变量指向任务栈栈顶。
00027行,将函数指针存入寄存器组的LR位置。当该任务第一次运行,恢复寄存器时,该函数指针就会被恢复到LR寄存器中,当程序跳转到LR时也就开始运行该函数了。
00028行,将任务栈栈顶地址存入寄存器组的SP位置。当该任务第一次运行时,栈内初始化的数据全部取出后,任务栈已经空了,此时SP应该指向栈顶,这时候函数才开始运行。将任务栈栈顶地址存入SP中,当该任务第一次运行,恢复寄存器时,任务栈栈顶地址就会被恢复到SP寄存器中,该任务就会从SP所指的地址开始存放栈数据,也就到达了控制任务栈的目的。
00029~00041行,任务刚创建时R0~R12寄存器中数据为无效值,因此此处全部填0。
00042行,将USR模式存入寄存器组的CPSR位置。这样当该任务第一次运行时,USR模式就会被恢复到CPSR寄存器中,任务就会从USR模式开始启动。MODE_USR是一个宏定义,其值为0x10,可以参考图5,代表USR模式。函数第一次运行时CPSR里面NZCV等各种状态均为0,因此此处CPSR寄存器中的状态位均被初始化为0。
00045~00052行,将每个任务的栈信息保存到它对应的全局变量中,供任务切换时使用。
创建任务所使用的函数都是通过WLX_TaskInit函数以这种隐式的方式开始运行的,并没有直接调用。在创建任务时就确定了任务所使用的根函数以及栈等其它信息,后续随着我们对操作系统的不断完善,还会有更多的信息被加入到任务中来,这也是任务不同于函数的地方,任务比函数拥有更多的信息,这样,操作系统才可以利用这些信息更方便的管理任务调度。
最后需要使用WLX_TaskStart函数从非操作系统状态开始进入到操作系统状态。WLX_TaskStart函数很简单,就是对上面介绍过的全局变量做一些初始化,然后调用任务切换函数WLX_TaskSwitch,WLX_TaskSwitch函数再调用WLX_ContextSwitch函数,将任务初始化函数WLX_TaskInit初始化的参数恢复到寄存器中开始运行,开始了第一个任务的运行,这样就进入到了操作系统状态。
WLX_TaskStart函数很简单,不再详细解释,代码如下:
00091 void WLX_TaskStart(void)
00092 {
00093
00094 gpuiCurTaskSpAddr = (U32*)NULL;
00095
00096
00097 guiNextTaskSp = guiTask1CurSp;
00098
00099
00100 guiCurTask = 1;
00101
00102 WLX_ContextSwitch();
00103 }
现在我们已经完成任务切换所需要的全部代码,在main函数里首先初始化硬件,然后调用WLX_TaskInit函数对2个任务进行初始化,最后调用WLX_TaskStart函数启动任务调度,这2个任务就开始交替执行了,交替向串口打印数据。
00014 S32 main(void)
00015 {
00016
00017 DEV_HardwareInit();
00018
00019
00020 WLX_TaskInit(1, TEST_TestTask1, TEST_GetTaskInitSp(1));
00021 WLX_TaskInit(2, TEST_TestTask2, TEST_GetTaskInitSp(2));
00022
00023
00024 WLX_TaskStart();
00025
00026 return 0;
00027 }
测试函数TEST_TestTask1和 TEST_TestTask2运行时函数调用关系如下:
—>TEST_TestTask1
—>WLX_TaskSwitch
—>WLX_ContextSwitch
—> TEST_TestTask2
—>WLX_TaskSwitch
—>WLX_ContextSwitch
—>TEST_TestTask1
—>……
我们来看看最终的效果:
通过图16我们可以看到这两个任务交替的运行,在代码里我们并没有直接运行TEST_TestTask1和TEST_TestTask2函数,而是采用操作系统创建任务、切换任务的原理运行这两个函数的。从串口工具的输出来看,已经完全实现了我们的设计!
读者还可以观看串口输出的视频,请登陆ifreecoding_新浪博客网站下载,该视频动态的记录了两个任务在串口工具上输出的过程,可以看到TEST_TestTask1任务执行1秒后切换到TEST_TestTask2任务,TEST_TestTask2任务执行2秒后切换到TEST_TestTask1任务,如此循环。
还有一点需要说明,某些函数具有返回值,但我并没有完全判断这些返回值,当不影响软件功能时我一般是采用viod屏蔽了函数返回值,以突出本手册介绍的重点。但我建议,如果你是在做一个项目的话,最好能判断函数的返回值,以增强系统的健壮性。