具有上下文切换的任务框架
在上一篇文章中, 我们介绍了上下文切换的主要内容和基本原理,提到STM32推荐使用具有最低优先级的PendSV中断触发切换, 并提供了一个简单的切换程序控制三色灯不断地闪烁红灯和绿灯。 本文将对上篇文章最后的例程进行详细的解释,得到一个具有上下文切换机制的任务框架。
虽然这只是一个简单的上下文切换机制,还算不上一个完整的操作系统,但我们已经可以看到操作系统的影子了。 我们会在后续的学习过程中逐渐完善。
程序的源码托管在了Github上, 这里打包了本文示例中所用的代码和工程文件。
1. 任务的创建
下面定义两个函数,它们就是框架中的任务。 这两个任务分别用来点亮三色灯,使其亮红色或者绿色。为了体现系统不断地在两个任务之间切换,以及任务的独立性,我们在两个任务中都用了超级循环。 在循环中先改变三色灯的颜色,然后延迟一段时间,主动调用一个函数task_switch切换到另一个任务中。
|
|
所谓的任务,实际上就是一段独立运行的程序。它涉及到两个要素:代码和数据。
代码比较好理解,就是为了满足任务目标而编写的一段控制逻辑,在我们这里就是上面看到的两个函数。 当然这应该是最简单的两个任务了,将它与我们在Linux或者Windows下写的程序对比一下,应该是一类的事物。 以源文件或者二进制文件存在硬盘上,没有装载入系统时叫程序,双击运行后的那个就是进程。 只是我们这里的程序是和系统一起编译的,伴随着系统一起运行。 以后随着我们研究的深入,会实现像桌面系统那样可以动态装载程序创建进程的机制。
数据则是控制逻辑的控制对象,也是各种资料里提及的变量。对硬件的操作,本质上是对映射在内存中寄存器的读写访问,也是一种变量。 在一个嵌入式的系统当中,变量实质上只有两种:静态的和动态的。静态的变量是指那些在编译过程中就已经能够确定地址和大小的变量,包括全局变量,以及用static关键字修饰的变量。 至于说这些变量是在内存中哪里,需要根据实际的链接器设置确定。这里的两个任务中所用到的LED_R, LED_G, LED_B是三个宏定义, 它们在编译过程中就会被后面的内容替换。其中涉及的指针GPIOI,就是一个全局变量, 它的地址是0x40022000,在一开始就已经确定了。
#define LED_R (GPIOI->ODR.bits.pin5)
#define LED_G (GPIOI->ODR.bits.pin6)
#define LED_B (GPIOI->ODR.bits.pin7)
动态的变量是指那些需要在运行过程中确定地址或者大小的变量,包括函数中的局部变量,通过类似malloc动态申请的变量。 由于目前我们还没有实现内存管理,所以暂时不会涉及动态申请内存。局部变量很好理解,这里延时中为了计数而申请的变量i就是一个局部的变量。 这类局部变量一般都是在任务的栈空间中实时申请的,函数结束返回时就会因为出栈操作自动释放掉。
为了创建一个任务,我们还需要为这个任务分配一段内存用作栈空间。该栈空间用作该任务进行函数调用、保存局部变量、保存上文的存储空间。 因为在STM32中,栈空间总是32位对齐的,所以我们为每个任务分配了1024*32位(也就是4kB)的栈空间。
#define TASKA_STK_SIZE 1024
#define TASKB_STK_SIZE 1024
static uint32 taskA_Stk[TASKA_STK_SIZE];
static uint32 taskB_Stk[TASKB_STK_SIZE];
为了以后编程方便,我们为任务定义一个xtos_task类型。它实际上是一个没有参数和返回值的函数指针,我们定义的taska和taskb都符合该指针的类型定义。
typedef void(*xtos_task)(void);
2. 任务描述符
我们知道在进行上下文切换时,最关键的一步就是把下文任务的栈顶指针装载到PSP中。 因此,除了上述的程序和栈空间之外,我们还需要实时记录各个任务的栈顶信息。
实际上,在Linux中专门定义了一个结构体struct task_struct(参考Linux Kernel Development. p24.), 在一些资料和博客中,这个结构体被称作是进程控制块(Process Control Block, PCB)。 它是一个巨大的结构体,描述了一个任务或者说是进程的所有信息,除了堆栈信息外还有进程的优先级、时间片等。 Linux的调度器就是根据这个结构体的内容判定什么时候该那个进程运行,并进行上下文切换的。 类似的,在μC/OS中也定义了一个结构体os_tcb,被称为是任务控制块(Task Control Block, tcb),它的作用于Linux中的task_struct是一致的。
我们还没涉及到任务调度的问题,只是简单的实现上下文切换,所以定义了任务描述符如下,其中只有一个栈顶指针的字段。
struct xtos_task_struct {
uint32 *pTopOfStack; /* 栈顶地址 */
};
然后,我们定义了全局变量gp_xtos_cur_task记录当前正在执行任务,gp_xtos_next_task记录调度后将要执行的任务。
struct xtos_task_struct *gp_xtos_cur_task;
struct xtos_task_struct *gp_xtos_next_task;
另外需要说明的是,因为在PendSV的中断服务函数中,我们用汇编写下了如下的语句, 实际上它访问的是gp_xtos_cur_task和gp_xtos_next_task的第一个字段,也就是这里的pTopOfStack。 这就要求以后对任务描述符进行扩展的时候,必须保证pTopOfStack是该结构体的第一个字段。
LDR R1, =gp_xtos_cur_task ; gp_xtos_cur_task->pTopOfStack = SP;
LDR R1, [R1]
STR R0, [R1] ; R0中记录了上文栈顶
LDR R0, =gp_xtos_cur_task ; gp_xtos_cur_task = gp_xtos_next_task
LDR R1, =gp_xtos_next_task
LDR R2, [R1]
STR R2, [R0]
3. 任务的初始化
现在我们有了任务,也为任务建立了堆栈和记录栈顶的指针,下面就需要对任务进行初始化,然后开始运行操作系统了。
如下,就是任务框架的main函数,大体上可以分为三个部分,第2,3行对通用IO端口进行初始化,并控制熄灭三色灯。 接着在第5,6行两次调用函数xtos_create_task,分别对taska和taskb的栈空间和任务描述符进行初始化。 最后,我们赋予gp_xtos_next_task指向taskA,调用xtos_start开启操作系统,并开始执行taskA任务。 另外在函数的最后还保留了一个空的超级循环,实际上我们这里是不会运行到这个超级循环的。
int main(void) {
led_init();
LED_R = LED_OFF; LED_G = LED_OFF; LED_B = LED_OFF;
xtos_create_task(&taskA, taska, &taskA_Stk[TASKA_STK_SIZE - 1]);
xtos_create_task(&taskB, taskb, &taskB_Stk[TASKB_STK_SIZE - 1]);
gp_xtos_next_task = &taskA;
xtos_start();
while(1) {}
}
xtos_create_task
这里调用的xtos_create_task函数的原型如下,前两个参数tcb和task分别是任务描述符和任务程序。而第三个参数则是任务栈顶指针, 我们在这里调用时,通过&taskA_Stk[TASKA_STK_SIZE - 1]传递栈空间中最后一个地址。这样做是因为,在STM32中栈指针是向下生长的, 也就是说,每次压栈操作,SP都会自减,而出栈操作则会自加。
void xtos_create_task(struct xtos_task_struct *tcb, xtos_task task, uint32 *stk);
在xtos_create_task内部,我们先用一个临时栈指针对参数stk做了如下的操作,以保证栈指针至少是4字节对齐的。
uint32 *pstk;
pstk = stk;
pstk = (uint32 *)((uint32)(pstk) & 0xFFFFFFF8uL);
接着我们先后向栈空间中依次写入Caller Saved Registers和Callee Saved Registers。 写入的顺序和处理器进入PendSV中断服务函数时压栈的顺序是一致的。 这样,当我们第一次通过上下文切换到某一个任务时,就能够保证从栈空间中装载进该任务的运行环境。 下面的代码中,只列举了部分的栈空间初始化。其中第2、3行为对PC和LR寄存器的初始化,除了这两个寄存器之外,其余的寄存器都可以随意赋值。
*(--pstk) = (uint32)0x01000000uL; // xPSR
*(--pstk) = (uint32)task; // Entry Point
*(--pstk) = (uint32)xtos_distroy_task; // R14 (LR)
*(--pstk) = (uint32)0x12121212uL; // R12
*(--pstk) = (uint32)0x03030303uL; // R3
*(--pstk) = (uint32)0x02020202uL; // R2
*(--pstk) = (uint32)0x01010101uL; // R1
*(--pstk) = (uint32)0x00000000u; // R0
PC赋予的是xtos_create_task的参数列表中的task,它指出了任务的入口函数。在第一次切换到该任务时,处理器将根据task找到任务的第一条代码。 LR赋予的是xtos_distroy_task这是一个只有一个超级循环的函数,当任务执行结束之后,就会调用该函数。 在我们的这个例子中还不会执行到这个函数,因为我们的两个任务都是具有超级循环的任务,它们会一直无休止地运行下去。
xtos_distroy_task虽然现在什么都没有做,但它为我们提供了一种回调机制,可以在其中完成任务结束后的资源回收工作。 随着我们的操作系统逐渐复杂起来,会不断地完善这个函数。
在xtos_create_task的最后,我们把栈顶地址赋予了任务描述符的pTopOfStack字段。
tcb->pTopOfStack = pstk;
xtos_start
xtos_start是一段用汇编写的函数,它做了4件事情:
- 设置PendSV的优先级位最低
- 赋栈指针PSP为0,标志着第一次进行上下文切换
- 触发PendSV中断,准备进入切换
- 开中断,退出
SCB_ICSR EQU 0xE000ED04 ; 中断控制和状态寄存器SCB->ICSR.
SCB_SHP14 EQU 0xE000ED22 ; 中断优先级寄存器SCB->SHP[14](priority 14).
PENDSV_PRI EQU 0xFF ; PendSV中断优先级(最低).
PENDSV_SET EQU 0x10000000 ; Value to trigger PendSV exception.
xtos_start
LDR R0, =SCB_SHP14 ; 设置PendSV的优先级
LDR R1, =PENDSV_PRI
STRB R1, [R0]
MOVS R0, #0 ; PSP = 0, 标识第一次进行上下文切换
MSR PSP, R0
LDR R0, =SCB_ICSR ; 触发PendSV,进行上下文切换
LDR R1, =PENDSV_SET
STR R1, [R0]
CPSIE I ; 开中断
xtos_start_hang
B xtos_start_hang ; Should never get here
4. 任务的调度
最后,我们再简单地介绍一下这里所用到的任务调度函数task_switch。它只是判定当前正在执行哪个任务,然后指定进行上下文切换后执行另一个任务, 最后调用xtos_context_switch进行上下文切换。
虽然代码上看没有什么,但五脏俱全。以后我们完善任务调度时,也将会这样的节奏,先根据任务描述符中的信息确定下一个要执行的任务是什么, 然后触发上下文切换。
void task_switch() {
if (gp_xtos_cur_task == &taskA)
gp_xtos_next_task = &taskB;
else
gp_xtos_next_task = &taskA;
xtos_context_switch();
}
5. 总结
在本文中,我们提到创建一个任务需要两个要素:代码和数据。需要先为完成某项功能编写代码,然后给该任务分配一段内存作为栈空间。 接着,我们定义了任务描述符,其中将会记录包括栈顶在内的,一个任务的所有信息。操作系统将根据这些信息进行任务调度, 其中栈顶信息则具体决定了上下文切换后要执行的任务。
在对任务进行初始化时,我们需要做两件事情:1. 初始化任务栈空间,以保证第一次切换到该任务时,能够正确的找到任务的入口。 2. 初始化任务描述符,记录任务的栈顶信息。只要保证PendSV的优先级是所有中断中最低的,PSP为0,就可以触发PendSV并开启中断了。 自此以后我们的操作系统就开启了。