RTOS中的多任务切换是操作系统与裸机编程的一个非常大的区别,一般逻辑变成运行在一个循环内,裸机编程很难实现两个事件的并行(这里的并行指的是宏观的并行),但是在操作系统中我们可以在逻辑上面实现两个任务的并行。每一个任务在操作系统层面可以看成一个进程,大学有修过操作系统的朋友肯定不陌生,其中一个非常重要的概念——PCB进程控制块,在单片机RTOS编程中我们称之为TCB即任务控制块,记录了一个任务的很多关键信息比如该任务函数的函数地址,任务函数的长度,任务的存活周期,任务函数的形参等。
笔者的实验与代码均基于野火的嵌入式系列,不做广告,提出感谢。毕竟在互联网泡沫巨大的今天,愿意踏踏实实写一本几十万字详细介绍操作系统底层的书也是很不容易,笔者虽然即将进入研究生进行cv方面的学习,但还是感觉当今国内应该多一些进行应用层以下的计算机底层研究人员,毕竟如果只以资本导向为研究方向的话,未来国内的人工智能可能会达到世界顶端,但是计算机底层的研究会严重滞后,闲聊至此。在本篇文章为了方便观察结果,设计了两个任务Task1,Task2.如下代码所示:
/*main.c*/
void Task1( void *p_arg )
{
for( ;; )
{
flag1 = 1;
delay( 100 );
flag1 = 0;
delay( 100 );
/* 任务切换,这里是手动切换 */
OSSched();
}
}
/* 任务2 */
void Task2( void *p_arg )
{
for( ;; )
{
flag2 = 1;
delay( 100 );
flag2 = 0;
delay( 100 );
/* 任务切换,这里是手动切换 */
OSSched();
}
}
其中flag1和flag2都在一个小延时函数后取反,OSSched函数的作用我们后面再说,本文后序内容便是研究如何做到Task1和Task2成为逻辑上面对等的两个任务并做到宏观上面的并行。
/*os.h*/
OS_EXT OS_TCB *OSTCBCurPtr;
OS_EXT OS_TCB *OSTCBHighRdyPtr;
OS_EXT OS_RDY_LIST OSRdyList[OS_CFG_PRIO_MAX];
OS_EXT OS_STATE OSRunning;
这里说明一下,OS_EXT是自定义的外部声明,可以直接看作extern。OS_TCB,OS_RDY_LIST,OS_STATE是三个自定义的数据类型,其中OS_TCB表示一个32位的无符号整数,表示stm32中的一个地址,OS_RDY_LIST是一个结构体,表示就绪队列,如下图:
/*os.h*/
typedef struct os_rdy_list OS_RDY_LIST;
struct os_rdy_list
{
OS_TCB *HeadPtr;
OS_TCB *TailPtr;
};
OS_STATE是一个8位的字符。
OSTCBCurPtr是指向正在运行的任务的TCB的指针,OSTCBHighRdyPtr是指向当前优先级最高,即即将运行的任务的指针,OSRdyList是任务的就绪队列(有操作系统知识基础的朋友一定不陌生),OSRunning是操作当前的运行状态。
/*main.c*/
#define TASK1_STK_SIZE 20
#define TASK2_STK_SIZE 20
static CPU_STK Task1Stk[TASK1_STK_SIZE];
static CPU_STK Task2Stk[TASK2_STK_SIZE];
static OS_TCB Task1TCB;
static OS_TCB Task2TCB;
void Task1( void *p_arg );
void Task2( void *p_arg );
任务的局部变量声明在main.c文件中其中TaskxStk是一个TASK1_STK_SIZE*32b大小的任务堆栈,Task1TCBx是指向任务栈顶的指针。这里将Taskx函数以指针变量的形式声明出来是因为在进行上下文切换时要将该地址传给cpu的pc(程序计数器)中。
由于篇幅的限制,函数具体的实现过程在这里不加赘述,主要介绍几个函数的功能以及函数的参数。
(1)
void OSInit (OS_ERR *p_err)
用于初始化操作系统的全局变量,参数为程序错误代码的枚举类型
(2)
void OSTaskCreate (OS_TCB *p_tcb,
OS_TASK_PTR p_task,
void *p_arg,
CPU_STK *p_stk_base,
CPU_STK_SIZE stk_size,
OS_ERR *p_err)
用于创建操作系统的任务,其中形参从上到下一次是:任务tcb栈顶指针,任务函数指针,任务参数,任务栈起始地址,任务栈大小,任务错误代码
(3)
CPU_STK *OSTaskStkInit (OS_TASK_PTR p_task,
void *p_arg,
CPU_STK *p_stk_base,
CPU_STK_SIZE stk_size)
用于初始化任务的tcb,参数依次是:任务函数指针,任务函数参数,任务栈起始地址,任务栈大小
(4)
void OSStart (OS_ERR *p_err)
用于启动操作系统,参数只有任务错误代码
笔者对于汇编语言的了解仅限于在计算机组成原理中学到的一些最基本的x86汇编指令,但还是非常勉强的看完了野火给的上下文切换的函数(-_ -!)不过自己实践时如果对于arm的汇编不太了解不妨跳过这一部分,只需要知道每一个汇编函数的意义基本就可以了,因为一个人想要完全实现操作系统不仅工程量巨大,所涉及的知识也涵盖硬件组成+汇编语言+c语言+操作系统知识+。。。。。,所以这部分个人感觉当前学习的意义并不大,因为学习成本太高,很容易在繁杂的计算机底层知识中迷茫,这里给出每一个函数的实现以及作用,完整的代码会在文末给出。
/*os_cpu_a.s*/
;********************************************************************************************************
; 开始第一次上下文切换
; 1、配置PendSV异常的优先级为最低
; 2、在开始第一次上下文切换之前,设置psp=0
; 3、触发PendSV异常,开始上下文切换
;********************************************************************************************************
OSStartHighRdy
LDR R0, = NVIC_SYSPRI14 ; 设置 PendSV 异常优先级为最低
LDR R1, = NVIC_PENDSV_PRI
STRB R1, [R0]
MOVS R0, #0 ; 设置psp的值为0,开始第一次上下文切换
MSR PSP, R0
LDR R0, =NVIC_INT_CTRL ; 触发PendSV异常
LDR R1, =NVIC_PENDSVSET
STR R1, [R0]
CPSIE I ; 开中断
OSStartHang
B OSStartHang ; 程序应永远不会运行到这里
;********************************************************************************************************
; PendSVHandler异常
;********************************************************************************************************
PendSV_Handler
; 任务的保存,即把CPU寄存器的值存储到任务的堆栈中
CPSID I ; 关中断,NMI和HardFault除外,防止上下文切换被中断
MRS R0, PSP ; 将psp的值加载到R0
CBZ R0, OS_CPU_PendSVHandler_nosave ; 判断R0,如果值为0则跳转到OS_CPU_PendSVHandler_nosave
; 进行第一次任务切换的时候,R0肯定为0
; 在进入PendSV异常的时候,当前CPU的xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0会自动存储到当前任务堆栈,同时递减PSP的值
STMDB R0!, {R4-R11} ; 手动存储CPU寄存器R4-R11的值到当前任务的堆栈
LDR R1, = OSTCBCurPtr ; 加载 OSTCBCurPtr 指针的地址到R1,这里LDR属于伪指令
LDR R1, [R1] ; 加载 OSTCBCurPtr 指针到R1,这里LDR属于ARM指令
STR R0, [R1] ; 存储R0的值到 OSTCBCurPtr->OSTCBStkPtr,这个时候R0存的是任务空闲栈的栈顶
; 任务的切换,即把下一个要运行的任务的堆栈内容加载到CPU寄存器中
OS_CPU_PendSVHandler_nosave
; OSTCBCurPtr = OSTCBHighRdyPtr;
LDR R0, = OSTCBCurPtr ; 加载 OSTCBCurPtr 指针的地址到R0,这里LDR属于伪指令
LDR R1, = OSTCBHighRdyPtr ; 加载 OSTCBHighRdyPtr 指针的地址到R1,这里LDR属于伪指令
LDR R2, [R1] ; 加载 OSTCBHighRdyPtr 指针到R2,这里LDR属于ARM指令
STR R2, [R0] ; 存储 OSTCBHighRdyPtr 到 OSTCBCurPtr
LDR R0, [R2] ; 加载 OSTCBHighRdyPtr 到 R0
LDMIA R0!, {R4-R11} ; 加载需要手动保存的信息到CPU寄存器R4-R11
MSR PSP, R0 ; 更新PSP的值,这个时候PSP指向下一个要执行的任务的堆栈的栈底(这个栈底已经加上刚刚手动加载到CPU寄存器R4-R11的偏移)
ORR LR, LR, #0x04 ; 确保异常返回使用的堆栈指针是PSP,即LR寄存器的位2要为1
CPSIE I ; 开中断
BX LR ; 异常返回,这个时候任务堆栈中的剩下内容将会自动加载到xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)
; 同时PSP的值也将更新,即指向任务堆栈的栈顶。在STM32中,堆栈是由高地址向低地址生长的。
NOP ; 为了汇编指令对齐,不然会有警告
END ; 汇编文件结束
这里讲一下上述的三个函数的具体含义,首先是OSStartHighRdy函数,该函数在执行时并不进行上下文切换,而是触发一次中断寄存器PendSV,在触发中断寄存器后,程序会自动跳到下面的中断服务函数PendSV_Handler,此函数是用来TCB上文的,通俗的说就是将在cpu寄存器中的任务控制块写回到RAM的任务栈中,很容易看出R0-R14寄存器是需要手动保存的,其余寄存器自动保存,这里给出任务堆栈的结构:
有两个地方需要我们特别注意一个是R15寄存器,它存储了一个指向任务函数的指针,在寄存器中每次任务开始前存储在程序计数器即PC中,R0寄存器存储了任务函数所传参数的地址,为了方便观察结果这里将参数设置成了void类型,在正常操作系统编写时由于不同的任务函数的参数不相同,一般设置为一个指向结构体的指针。
OS_CPU_PendSVHandler_nosave函数的作用就是将即将执行的任务的TCB写入到cpu的寄存器中以完成上下文的切换。
在程序执行时的流程比较简单,如图所示:
在系统初始化,全局变量初始化,任务堆栈初始化后操作系统便进入了运行状态,首先会产生一次PendSV中断,系统检测到当前任务堆栈指针尚处于初始化状态后会自动将第一次要执行的任务写入,在上文中,每一次任务执行到末尾时会调用OSSched()函数,其目便是让操作系统产生一次PendSV中断,然后执行终端服务函数中的上下文其切换,在完成上下文切换后,此时cpu的PC寄存器已经是另一个任务函数的首地址,程序会进入到另一个任务中运行,在另一个任务执行后系统又会切换过来,并循环这个过程。当然这个过程知识逻辑上面的并行,用裸机编程的NVIC也可以实现这个效果,但是在引入时间片等其他元素后,RTOS将会大大简化编程难度,该试验仅演示多任务切换的过程,笔者说是并行也不准确,还需要后续的优化。
在keil5中使用调试功能,观察flag1和flag2的运行情况如图:
虽然flag1和flag2并没有在同一时间进行切换,但是仍然可将Task1和Task2视作宏观的并行,在一些更为复杂的操作系统(如windows,Linux)中,上下文切换会更加的细化,每日一次切换后的pc值并不是下一个要执行任务的首地址,而是该任务执行的最近一条指令的下一条指令,这就可以让两个任务的执行事件差缩小到一个时间片内,这样这样可以很大程度上的提升多个任务在执行时的并行性,但频繁进行上下文的切换也会让操作系统的开销更大,RTOS的作用其中之一便是平衡并行性和上下文切换之间的矛盾,找到一个适中的办法。
上文中的进程切换可作为一个参考,由于代码比较多,所以看起来可能感觉很乱,有真正想要了解其过程的朋友可以下载下面的源码
链接:https://pan.baidu.com/s/1xTmXt4h-r7-r0jJLMTUkKA
提取码:p4cw
最后说一两句题外话吧,笔者未来的学习方向和从事工作可能都和操作系统没有生么太大关系,但是在科技竞争愈演愈烈的今天,想要打破发达国家对国内的封锁,我们在很多领域都需要有一批人才站出来,手机soc的研发,FPGA芯片的研发,操作系统的研发这些其实都是国内目前的短板,互联网泡沫终有过去的那天,可能若干年后中国在建立在应用层之上的人工智能领域有着百万千万的人才储备,但计算机基础领域在北上广深的核心人才都很难凑成三位数。我们都不希望看到那一天,所以在此向每一个从事计算机基础研究工作的学者,工程师以及学生致以最诚挚的敬意!