第3章
任务的定义与任务切换
本章我们真正开始从0到1写RTOS。必须学会创建任务,并重点掌握任务是如何切换的。因为任务的切换是由汇编代码来完成的,所以代码看起来比较难懂,但是我们会尽力把代码讲得透彻。如果不能掌握本章内容,那么后面的内容根本无从下手。
在本章中,我们会创建两个任务,并让这两个任务不断地切换,任务的主体都是让一个变量按照一定的频率翻转,通过KEIL的软件仿真功能,在逻辑分析仪中观察变量的波形变化,最终的波形图如图3-1所示。
其实,图3-1所示的波形图并不是真正的多任务系统中任务切换的效果图,这个效果其实可以完全由裸机代码实现,具体参见代码清单3-1。
在多任务系统中,两个任务不断切换的效果图应该如图3-2所示,即两个变量的波形是完全一样的,就好像CPU在同时做两件事,这才是多任务的意义。虽然两者的波形图一样,但是代码的实现方式是完全不同的,由原来的顺序执行变成了任务的主动切换,这是根本区别。本章只是开始,我们先掌握好任务是如何切换的,在后面章节中,会陆续完善功能代码,加入系统调度,实现真正的多任务。
3.1 多任务系统中任务的概念
在裸机系统中,系统的主体就是main()函数中顺序执行的无限循环,在这个无限循环中,CPU按照顺序完成各种操作。在多任务系统中,根据功能不同,可以把整个系统分割成一个个独立的且无法返回的函数,这种函数称为任务,也有人称之为线程。任务的大概形式具体参见代码清单3-2。
3.2 创建任务
3.2.1 定义任务栈
先回想一下,在一个裸机系统中,如果有全局变量,有子函数调用,有中断发生,那么系统在运行时,全局变量放在哪里?子函数调用时,局部变量放在哪里?中断发生时,函数返回地址放在哪里?如果只是单纯的裸机编程,可以不考虑上述问题,但是如果要写一个RTOS,就必须明确这些参数是如何存储的。在裸机系统中,它们统统放在栈中。栈是单片机RAM中一段连续的内存空间,其大小由启动文件中的代码配置,具体参见代码清单3-3,最后由C库函数__main进行初始化。
但是,在多任务系统中,每个任务都是独立的、互不干扰的,所以要为每个任务都分配独立的栈空间,这个栈空间通常是一个预先定义好的全局数组。这些任务栈也存在于RAM中,能够使用的最大的栈尺寸也是由代码清单3-3中的Stack_Size决定的。只是多任务系统中任务的栈就是在一个统一的栈空间里面分配好一个个独立的“房间”,每个任务只能使用各自的房间,而需要在裸机系统中使用栈时,则可以天马行空,在栈里寻找任意空闲空间加以使用。
本章我们要实现两个变量按照一定的频率轮流翻转,需要用两个任务来实现,那么就需要定义两个任务栈,具体参见代码清单3-4。在多任务系统中,有多少个任务就需要定义多少个任务栈。
代码清单3-4(1):任务栈的大小由宏定义控制,在C/OS-III中,空闲任务的栈最小应该大于128,这里的任务栈也暂且配置为128。
代码清单3-4(2):任务栈其实就是一个预先定义好的全局数据,此处数据类型为CPU_STK。在C/OS-III中,凡是涉及数据类型的地方,C/OS-III都会将标准的C数据类型用typedef 重新设置一个类型名,命名方式则采用见名知义的方式且使用大写字母。凡是与CPU类型相关的数据类型统一在cpu.h中定义,与操作系统相关的数据类型则在os_type.h中定义。CPU_STK就是与CPU相关的数据类型,具体参见代码清单3-5。首次使用cpu.h,需要自行在C-CPU文件夹中新建并添加到工程的C/CPU组中。代码清单3-5中除了CPU_STK外,其他数据类型重定义是本章后面内容中需要用到的,这里统一给出,后面将不再赘述。
3.2.2 定义任务函数
任务是一个独立的函数,函数主体无限循环且不能返回。本章定义的两个任务具体参见代码清单3-6。
代码清单3-6(1):如果要在KEIL逻辑分析仪中观察波形的变量,则需要将其定义成全局变量,且要以Bit模式观察,不能使用默认的模拟量。
代码清单3-6(2)(3):正如介绍的那样,任务是一个独立的、无限循环且不能返回的函数。
3.2.3 定义任务控制块
在裸机系统中,程序的主体是CPU按照顺序执行的,而在多任务系统中,任务的执行是由系统调度的。系统为了顺利地调度任务,为每个任务都额外定义了一个任务控制块(Task Control Block,TCB),这个任务控制块相当于任务的身份证,里面存有任务的所有信息,比如任务栈、任务名称、任务形参等。有了TCB,以后系统对任务的全部操作都可以通过这个TCB来实现。TCB是一个新的数据类型,在os.h头文件中声明(第一次使用os.h时需要自行在文件夹C/OS-IIISource中新建并添加到工程的C/OS-III Source组),有关TCB具体的声明参见代码清单3-7,使用它可以为每个任务都定义一个TCB实体。
代码清单3-7(1):在C/OS-III中,所有的数据类型都会被重新设置一个名称且用大写字母表示。
代码清单3-7(2):目前TCB里面的成员还比较少,只有栈指针和栈大小。为了以后操作方便,我们把栈指针作为TCB的第一个成员。
此处,在app.c文件中为两个任务定义的TCB具体参见代码清单3-8。
3.2.4 实现任务创建函数
任务栈、任务的函数实体、任务的TCB最终需要联系起来才能由系统进行统一调度,这个联系的工作由任务创建函数OSTaskCreate()实现,该函数在os_task.c中定义(第一次使用os_task.c时需要自行在文件夹C/OS-IIISource中新建并添加到工程的C/OS-III Source组),所有与任务相关的函数都在这个文件中定义。OSTaskCreate()函数的实现具体参见代码清单3-9。
代码清单3-9:OSTaskCreate()函数遵循C/OS-III中的函数命名规则,以OS开头,表示这是一个外部函数,可以由用户调用;以OS_开头的函数则表示内部函数,只能在C/OS-III内部使用。紧接着是文件名,表示该函数放在哪个文件中,最后是函数功能名称。
代码清单3-9(1):p_tcb是任务控制块指针。
代码清单3-9(2):p_task 是任务名,类型为OS_TASK_PTR,原型声明在os.h文件中,具体参见代码清单3-10。
代码清单3-9(3):p_arg是任务形参,用于传递任务参数。
代码清单3-9(4):p_stk_base 用于指向任务栈的起始地址。
代码清单3-9(5):stk_size 表示任务栈的大小。
代码清单3-9(6):p_err 用于存储错误码。C/OS-III中为函数的返回值预先定义了很多错误码,通过这些错误码可以知道函数出现错误的原因。为了方便,我们现在把C/OS-III中所有的错误码都给出来。错误码是枚举类型的数据,在os.h中定义,具体参见代码清单3-11。
代码清单3-9(7):OSTaskStkInit()是任务栈初始化函数。当任务第一次运行时,加载到CPU寄存器的参数就放在任务栈中,在任务创建时,预先初始化好栈。OSTaskStkInit()函数在os_cpu_c.c中定义(第一次使用os_cpu_c.c时需要自行在文件夹C-CPU中新建并添加到工程的C/CPU组),具体参见代码清单3-12。
代码清单3-12(1):p_task是任务名,表示任务的入口地址,在任务切换时,需要加载到R15,即PC寄存器,这样CPU就可以找到要运行的任务。
代码清单3-12(2):p_arg 是任务的形参,用于传递参数,在任务切换时,需要加载到寄存器R0。R0寄存器通常用来传递参数。
代码清单3-12(3):p_stk_base 表示任务栈的起始地址。
代码清单3-12(4):stk_size 表示任务栈的大小,数据类型为CPU_STK_SIZE,在Cortex-M3内核的处理器中等于4字节,即一个字。
代码清单3-12(5):获取任务栈的栈顶地址,ARMCM3处理器的栈是由高地址向低地址生长的,所以在初始化栈之前,要获取栈顶地址,然后将栈地址逐一递减即可。
代码清单3-12(6):任务第一次运行时,加载到CPU寄存器的环境参数要预先初始化好。初始化的顺序固定,首先是异常发生时自动保存的8个寄存器,即xPSR、R15、R14、R12、R3、R2、R1和R0。其中xPSR寄存器的位24必须是1,R15 PC指针必须存储任务的入口地址,R0必须是任务形参。对于R14、R12、R3、R2和R1,为了调试方便,应填入与寄存器号相对应的十六进制数。
代码清单3-12(7):剩下的是8个需要手动加载到CPU寄存器的参数,为了调试方便,应填入与寄存器号相对应的十六进制数。
代码清单3-12(8):返回栈指针p_stk,这时p_stk指向剩余栈的栈顶。
代码清单3-9(8):将剩余栈的栈顶指针p_sp保存到TCB的第一个成员StkPtr中。
代码清单3-9(9):将任务栈的大小保存到TCB的成员StkSize中。
代码清单3-9(10):函数执行到这里表示没有错误,即OS_ERR_NONE。
任务创建好之后,需要把任务添加到就绪列表,表示任务已经就绪,系统随时可以调度。将任务添加到就绪列表的代码具体参见代码清单3-13。
代码清单3-13(1)(2):把TCB指针放到OSRdyList数组中。OSRdyList是一个类型为OS_RDY_LIST的全局变量,在os.h中定义,具体参见代码清单3-14。
代码清单3-14(1):OS_CFG_PRIO_MAX是一个定义,表示这个系统支持多少个优先级(刚开始暂时不支持多个优先级,后面的章节中会支持),目前仅用来表示这个就绪列表可以存储多少个TCB指针。具体的宏在os_cfg.h中定义(第一次使用os_cfg.h时需要自行在文件夹C/OS-IIISource中新建并添加到工程的C/OS-III Source组),具体参见代码清单3-15。
代码清单3-14(2):OS_RDY_LIST是就绪列表的数据类型,在os.h中声明,具体参见代码清单3-16。
代码清单3-16(1):C/OS-III中会为每个数据类型重新设置一个字母大写的名称。
代码清单3-16(2):OS_RDY_LIST中目前只有两个TCB类型的指针,一个是头指针,一个是尾指针。本章实验只用到头指针,用来指向任务的TCB。只有当后面讲到同一个优先级支持多个任务时才需要使用头尾指针来将TCB串成一个双向链表。
代码清单3-14(3):OS_EXT是一个在os.h中定义的宏,具体参见代码清单3-17。
这段代码的意思是,如果没有定义OS_GLOBALS这个宏,那么OS_EXT就为空,否则为extern。
在C/OS-III中,需要使用很多全局变量,这些全局变量都在os.h头文件中定义,但是os.h会被包含进很多文件中,那么编译时os.h中定义的全局变量就会出现重复定义的情况,而我们只想将os.h中的全局变量只定义一次,涉及包含os.h头文件时只是声明。有人提出可以加extern,那么该如何加?
通常采取的做法是在C文件中定义全局变量,然后在头文件中需要使用全局变量的位置添加extern声明,但是C/OS-III中文件非常多,这种方法可行,但不现实,所以就有了在os.h头文件中定义全局变量,然后在os.h文件的开头加上代码清单3-17中宏定义的方法。但是这样还没有成功,C/OS-III另外新建了一个os_var.c文件(第一次使用os_var.c时需要自行在文件夹C/OS-IIISource中新建并添加到工程的C/OS-III Source组),其中包含了os.h,且只在这个文件中定义OS_GLOBALS这个宏,具体参见代码清单3-18。
经过这样的处理之后,在编译整个工程时,只有var.c中os.h的OS_EXT才会被替换为空,即变量的定义,其他包含os.h的文件因为没有定义OS_GLOBALS这个宏,所以OS_EXT会被替换成extern,即变成了变量的声明。这样就实现了在头文件中定义变量。
在C/OS-III中,将任务添加到就绪列表其实是在OSTaskCreate()函数中完成的。每当任务创建好就把任务添加到就绪列表,表示任务已经就绪,只是目前这里的就绪列表的实现还比较简单,不支持优先级,也不支持双向链表,只是简单地将TCB放到就绪列表的数组中。第8章将专门讲解就绪列表,等完善就绪列表之后,再把这部分的操作放回OSTaskCreate()函数中。
3.3 操作系统初始化
操作系统初始化一般是在硬件初始化完成之后进行的,主要是初始化C/OS-III中定义的全局变量。操作系统初始化用OSInit()函数实现。OSInit()函数在文件os_core.c中定义(第一次使用os_core.c时需要自行在文件夹C/OS-IIISource中新建并添加到工程的C/OS-III Source组),具体实现参见代码清单3-19。
代码清单3-19(1):系统用一个全局变量OSRunning指示其运行状态,刚开始初始化系统时,默认为停止状态,即OS_STATE_OS_STOPPED。
代码清单3-19(2):全局变量OSTCBCurPtr是系统用于指向当前正在运行的任务的TCB指针,在任务切换时用得到。
代码清单3-19(3):全局变量OSTCBHighRdyPtr用于指向就绪列表中优先级最高的任务的TCB,在任务切换时用得到。本章暂时不支持优先级,则用于指向第一个运行的任务的TCB。
代码清单3-19(4):OS_RdyListInit()用于初始化全局变量OSRdyList[],即初始化就绪列表。OS_RdyListInit()在os_core.c文件中定义,具体实现参见代码清单3-20。
代码清单3-19(5):代码运行到这里表示没有错误,即OS_ERR_NONE。
代码清单3-19中的全局变量OSTCBCurPtr和OSTCBHighRdyPtr均在os.h中定义,具体参见代码清单3-21。OS_STATE_OS_STOPPED 这个表示系统运行状态的宏也在os.h中定义,具体参见代码清单3-22。
3.4 启动系统
任务创建好且系统初始化完毕之后,就可以启动系统了。系统启动函数OSStart()在os_core.c中定义,具体实现参见代码清单3-23。
代码清单3-23(1):如果系统是第一次启动,则if为真,继续往下运行。
代码清单3-23(2):OSTCBHighRdyPtr 指向第一个要运行的任务的TCB。因为暂时不支持优先级,所以系统启动时先手动指定第一个要运行的任务。
代码清单3-23(3):OSStartHighRdy()用于启动任务切换,即配置PendSV的优先级为最低,然后触发PendSV异常,在PendSV异常服务函数中进行任务切换。该函数不再返回,在文件os_cpu_a.s中定义(第一次使用os_cpu_a.s时需要自行在文件夹C/OS-IIIPorts中新建并添加到工程的C/OS-III Ports组),用汇编语言编写,具体实现参见代码清单3-24。os_cpu_a.s文件中涉及的ARM汇编指令的用法如表3-1所示。
代码清单3-24中涉及的NVIC_INT_CTRL、NVIC_SYSPRI14、NVIC_PENDSV_PRI和NVIC_PENDSVSET这4个常量在os_cpu_a.s的开头定义,具体参见代码清单3-25,有关这4个常量的含义参见代码注释即可。
代码清单3-24(1):配置PendSV的优先级为0XFF,即最低。在C/OS-III中,上下文切换是在PendSV异常服务程序中执行的,配置PendSV的优先级为最低,从而排除了在中断服务程序中执行上下文切换的可能。
代码清单3-24(2):设置PSP的值为0,开始第一个任务切换。在任务中,使用的栈指针都是PSP,后面如果判断出PSP为0,则表示第一次任务切换。
代码清单3-24(3):触发PendSV异常,如果中断启用且编写了PendSV异常服务函数,则内核会响应PendSV异常,去执行PendSV异常服务函数。
代码清单3-24(4):开中断,因为有些用户在main()函数中会先关掉中断,等全部初始化完成后,在启动操作系统时才开中断。为了快速地开关中断,ARM CM3专门设置了一条 CPS指令,有4种用法,具体参见代码清单3-26。
代码清单3-26中,PRIMASK和FAULTMASK是ARM CM3中3个中断屏蔽寄存器中的两个,还有一个是BASEPRI,有关这3个寄存器的详细用法如表3-2所示。
3.5 任务切换
当调用OSStartHighRdy()函数,触发PendSV异常后,就需要编写PendSV异常服务函数,然后在其中进行任务切换。PendSV异常服务函数具体参见代码清单3-27。PendSV异常服务函数名称必须与启动文件向量表中PendSV的向量名一致,如果不一致,则内核无法响应用户编写的PendSV异常服务函数,只响应启动文件中默认的PendSV异常服务函数。启动文件中为每个异常都编写好了默认的异常服务函数,函数体都是一个死循环,当发现代码跳转到这些启动文件中默认的异常服务函数时,就要检查异常函数名称是否写错了,是否与向量表中的一致。PendSV_Handler函数中涉及的ARM汇编指令的讲解如表3-3所示。
代码清单3-27中,PendSV异常服务中主要完成两项工作,一是保存上文,即保存当前正在运行的任务的环境参数;二是切换下文,即把下一个需要运行的任务的环境参数从任务栈中加载到CPU寄存器,从而实现任务的切换。
PendSV异常服务中用到了OSTCBCurPtr和OSTCBHighRdyPtr这两个全局变量,它们在os.h中定义,要想在汇编文件os_cpu_a.s中使用,必须将这两个全局变量导入os_cpu_a.s中,具体导入方法参见代码清单3-28。
代码清单3-28(1):使用IMPORT关键字将os.h中的OSTCBCurPtr和OSTCBHighRdyPtr这两个全局变量导入该汇编文件,从而该汇编文件可以使用这两个变量。如果是函数,也可以使用IMPORT导入。
代码清单3-28(2):使用EXPORT关键字导出该汇编文件中的OSStartHighRdy和PendSV_Handler函数,让外部文件可见。除了使用EXPORT导出外,还要在某个C的头文件中声明这两个函数(在C/OS-III中是在os_cpu.h中声明),这样才可以在C文件中调用这两个函数。
接下来具体讲解代码清单3-27中主要代码的含义。
代码清单3-27(1):关中断,NMI和HardFault除外,防止上下文切换被中断。在上下文切换完毕之后,会重新开中断。
代码清单3-27(2):将PSP的值加载到R0寄存器。MRS是ARM 32位数据加载指令,功能是加载特殊功能寄存器的值到通用寄存器。
代码清单3-27(3):判断R0,如果值为0,则跳转到OS_CPU_PendSVHandler_nosave。进行第一次任务切换时,PSP在OSStartHighRdy初始化为0,所以这时R0肯定为0,因此跳转到OS_CPU_PendSVHandler_nosave。CBZ是ARM 16位转移指令,用于比较,结果为0则跳转。
代码清单3-27(4):当第一次切换任务时,会跳转到这里运行。当执行过一次任务切换之后,则顺序执行到这里。这个标号以后的内容属于下文切换。
代码清单3-27(5):加载 OSTCBCurPtr 指针的地址到R0。在ARM汇编中,操作变量都属于间接操作,即要先获取这个变量的地址。这里LDR属于伪指令,不是ARM指令。例如“LDR Rd, = label”,如果label是立即数,那么Rd等于立即数;如果label是一个标识符,比如指针,那么存到Rd的就是label这个标识符的地址。
代码清单3-27(6):加载 OSTCBHighRdyPtr 指针的地址到R1,这里LDR也属于伪指令。
代码清单3-27(7):加载 OSTCBHighRdyPtr 指针到R2,这里LDR属于ARM指令。
代码清单3-27(8):存储 OSTCBHighRdyPtr 到 OSTCBCurPtr,实现将下一个要运行的任务的TCB存储到OSTCBCurPtr。
代码清单3-27(9):加载 OSTCBHighRdyPtr 到 R0。TCB中第一个成员是栈指针StkPtr,所以这时R0等于StkPtr,后续操作任务栈都是通过操作R0来实现,不需要操作StkPtr。
代码清单3-27(10):将任务栈中需要手动加载的内容加载到CPU寄存器R4~R11,同时会递增R0,让R0指向空闲栈的栈顶。LDMIA中的I是increase的缩写,A是after的缩写,R0后面的“!”表示会自动调节R0中存储的指针。当任务被创建时,任务的栈会被初始化,初始化的流程是先让栈指针StkPtr指向栈顶,然后从栈顶开始依次存储异常退出时会自动加载到CPU寄存器的值和需要手动加载到CPU寄存器的值,具体代码实现参见代码清单3-12中的OSTaskStkInit()函数,栈空间的分布情况如图3-3所示。当把需要手动加载到CPU的栈内容加载完毕之后,栈空间的分布图和栈指针指向如图3-4所示,注意这时StkPtr不变,改变的是R0。
代码清单3-27(11):更新PSP的值,这时PSP与图3-4中R0的指向一致。
代码清单3-27(12):设置LR寄存器的位2为1,确保异常退出时使用的栈指针是PSP。当异常退出后,就切换到就绪任务中优先级最高的任务继续运行。
代码清单3-27(13):开中断。上下文切换已经完成了3/4,剩下的就是异常退出时自动保存的部分。
代码清单3-27(14):异常返回,这时任务栈中的剩余内容将会自动加载到xPSR、PC(任务入口地址)、R14、R12、R3、R2、R1、R0(任务的形参)寄存器,同时PSP的值也将更新,即指向任务栈的栈顶,这样就切换到了新的任务。这时栈空间的分布具体如图3-5所示。
代码清单3-27(15):手动存储CPU寄存器R4~R11的值到当前任务栈。当出现异常,进入PendSV异常服务函数时,当前CPU寄存器xPSR、PC(任务入口地址)、R14、R12、R3、R2、R1、R0会自动存储到当前任务栈,同时递减PSP的值,这个时候当前任务的栈空间分布如图3-6所示。当执行“STMDB R0!, {R4-R11}”后,当前任务的栈空间分布图如图3-7所示。
代码清单3-27(16):加载 OSTCBCurPtr 指针的地址到R1,这里LDR属于伪指令。
代码清单3-27(17):加载 OSTCBCurPtr 指针到R1,这里LDR属于ARM指令。
代码清单3-27(18):存储R0的值到OSTCBCurPtr->OSTCBStkPtr,这时R0存储的是任务空闲栈的栈顶。执行到了这里,才完成了上文的保存。这时当前任务的栈空间分布和栈指针指向如图3-8所示。
3.6 main()函数
main()函数在文件app.c中编写,app.c文件的完整代码参见代码清单3-29。
代码清单3-29中的所有代码在本小节之前都有循序渐进的讲解,这里只是融合在一起放在main()函数中。Task1和Task2并不会真正自动切换,而是在各自的函数体中加入了OSSched()函数来实现手动切换。OSSched()函数的实现具体参见代码清单3-30。
OSSched()函数的调度算法很简单,即如果当前任务是任务1,那么下一个任务就是任务2,如果当前任务是任务2,那么下一个任务就是任务1,然后调用OS_TASK_SW()函数触发PendSV异常,再在PendSV异常中实现任务的切换。在此后的章节中,我们将继续完善,加入SysTick中断,从而实现系统调度的自动切换。OS_TASK_SW()函数其实是一个宏定义,具体是往中断及状态控制寄存器SCB_ICSR的位28(PendSV异常启用位)写入1,从而触发PendSV异常。OS_TASK_SW()函数在os_cpu.h文件中实现(第一次使用os_cpu.h时需要自行在文件夹C-CPU中新建并添加到工程的C/CPU组),文件的内容具体参见代码清单3-31。
3.7 实验现象
本章代码讲解完毕,接下来是软件调试仿真,具体过程如图3-9~图3-13所示。
至此,本章讲解完毕。但是只是把本章的内容看完,再仿真看看波形是远远不够的,应该是把任务栈、TCB、OSTCBCurPtr和OSTCBHighRdyPtr这些变量统统添加到观察窗口,然后单步执行程序,观察这些变量是如何变化的,特别是任务切换时,CPU寄存器、任务栈和PSP是如何变化的,让机器执行代码的过程在脑海中演示一遍。如图3-14所示就是我们在进行仿真调试时出现的观察窗口。