从这节课时开始,我们开始具体学习RTOS相关的实现。

整体上而言,该章的所有课时都有一定的难度,需要你付出比较大的精力。但是一旦掌握的话,就意味着你掌握了RTOS的核心工作原理。

主要内容

本课时主要介绍两大内容:任务的定义和任务切换的本质。

任务的定义

虽然在使用RTOS时,我们一般用函数来表示任务,但实际任务所包含的东西更多。
任务定义与切换原理 - 图1

任务切换本质

结合RTOS原理及功能简介课时中的这幅图,我们知道RTOS负责在各个任务之间来回切换运行,来实现看起来像是多个任务同时运行的表现
任务定义与切换原理 - 图2
为了实现这种来回切换,我们需要对任务的状态不断地进行保存和恢复。
任务定义与切换原理 - 图3
根据课程内容,你需要理解的是:我们保存任务的哪些状态?保存在哪里?
最后我们得出的结论是:将任务的状态保存在任务的专属堆栈中,而这些状态中首要保存的就是内核寄存器的值。
任务定义与切换原理 - 图4

重点难点

这节课程的难点在于理解RTOS是怎样实现多个任务之间来回切换执行。以我们前面课时的学习指南中举的例子说明。

假如你(CPU)要在一天中分时负责多个同事的项目(任务),那么你每段时间只能做一个项目(任务)的一小部分(一小部分代码),然后记下这个项目的完成状态(任务状态);接下来找到下一个要做的项目,根据其之前保存的项目完成状态(任务状态),再在其基础之上完成。 想像一下,如果你希望能够每天都能无错地开展各个项目,那么对你的要求就是要正确地记录项目的状态,需要时根据项目状态继续下一步行动。 RTOS如果想分时执行多个任务,其原理正是如此。

注意事项

常见问题

任务创建时堆栈为什么传入1024

感谢同学 @小钟 的提问
Q:刚看到任务切换原理部分,任务堆栈这里是把数组高地址传入的,应该是1023的地址吧,1024的话不是到隔壁内存去了么。源码如下:
tTaskStack task1Env[1024];
tTaskStack task2Env[1024];
tTaskInit(&tTask1, task1, (void )0x11111111, &task1Env[1024]);
tTaskInit(&tTask2, task2, (void
)0x22222222, &task2Env[1024]);
A: 回顾在《芯片内核简介》中学到的,Cortex‐M3 使用的是“向下生长的满栈”模型,也即堆栈指针总是指向最后一个压入堆栈的单元。
而stack参数,指的是任务初始的堆栈指针。
void tTaskInit (tTask task, void (_entry)(void ), void _param, uint32_t * stack)
那么显然,对于tTaskStack task2Env[1024],stack就不是指向1023,而是1024,即task2Env的末端。

  • 当执行压栈指令,如PUSH;或者发生异常时,硬件自动压栈,堆栈指针会先减4,即调整到1023单元处,然后再向1023单元处压入值。所以,压栈操作不会写到task2Env之外的内存空间。
  • 在执行tTaskInit对堆栈进行初始化时(在后两节视频课程介绍)

这个过程由我们手动对堆栈进行了一些初始化操作,可以看到操作代码为(—stack)=???,也是先将堆栈指针减4,然后再存入具体的值。这个过程和使用PUSH执行压栈方式是一样的。也即,在这个过程中也不会使用1024单元,而是使用1023、1022….各个单元。
void tTaskInit (tTask task, void (_entry)(void ), void _param, uint32_t
stack)
{
(—stack) = (unsigned long)(1<<24); // XPSR, 设置了Thumb模式,恢复到Thumb状态而非ARM状态运行
(—stack) = (unsigned long)entry; // 程序的入口地址
(—stack) = (unsigned long)0x14; // R14(LR), 任务不会通过return xxx结束自己,所以未用
(—stack) = (unsigned long)0x12; // R12, 未用
(—stack) = (unsigned long)0x3; // R3, 未用
(—stack) = (unsigned long)0x2; // R2, 未用
(—stack) = (unsigned long)0x1; // R1, 未用
(—stack) = (unsigned long)param; // R0 = param, 传给任务的入口函数
(—stack) = (unsigned long)0x11; // R11, 未用
(—stack) = (unsigned long)0x10; // R10, 未用
(—stack) = (unsigned long)0x9; // R9, 未用
(—stack) = (unsigned long)0x8; // R8, 未用
(—stack) = (unsigned long)0x7; // R7, 未用
(—stack) = (unsigned long)0x6; // R6, 未用
(—stack) = (unsigned long)0x5; // R5, 未用
(—stack) = (unsigned long)0x4; // R4, 未用

  1. task->stack = stack; // 保存最终的值

}
实际上,要求传递任务堆栈的初始指针并不好,影响可移植性。试想一下,如果我们的代码要求在两种不同类型的CPU间迁移。
针对Cortex-M类型的CPU,任务初始化代码如下。
tTaskInit(&tTask1, task1Entry, (void *)0x11111111, &task1Env[1024]);
如果另一种类型的CPU堆栈指针总是指向下一个空单元。也就是压栈时先向当前堆栈指针指向的单元写入值,然后再将地址递减。那么任务的初始化代码就变成。

  1. tTaskInit(&tTask1, task1Entry, (void *)0x11111111, &task1Env[1023]);

这就导致了:因为采用的CPU不同, tTaskInit()的调用必须修改。
负责应用开发但不懂这个问题的同学,可能会很烦:我干吗要知道传递&task1Env[1023],还是&task1Env[1024]?
所以,在本课程的后续课程, tTaskInit()会修改为:
void tTaskInit (tTask task, void (_entry)(void ), void _param, uint32_t prio, uint32_t stack, uint32_t size)
堆栈相关的参数含义,变成 stack – 堆栈空间的起始地址,size – 堆栈空间的大小。
无论是移植到哪种类型的CPU,任务初始化代码就统一变成了:
tTaskInit(&tTask1, task1Entry, (void
)0x11111111, 0, task1Env, sizeof(task1Env));
如果cpu大端模式是不是应该用&task2Env[0]
:在任务初始化时,那如果cpu大端模式这里是不是应该用&task2Env[0]?例如:
tTaskInit(&tTask2, task2, (void *)0x22222222, &task2Env[0]);
A:不是的。这个是和m3的堆栈特性有关。
首先,m3的堆栈是向低地址增长的。
其次,m3的堆栈是满递减方式压栈,先减地址,再压栈。
不管是不是大端,只要符合上面两种情况,都用1024

任务能否使用MSP作为堆栈指针?

Q:任务直接使用MSP是不是能省掉两行汇编代码?
A:你指的是任务也使用MSP作为栈指针? 这样会有个问题哈,因为异常发生时也是使用MSP为作栈指针的。那么每个任务的栈空间可能要预留多点,给异常处理中的代码用。这样存储资源的开销就大了。 当然,你可能会想为异常单独配一个栈,在异常发生时,切换到这个栈,返回时再切换回来,这样又太复杂了。
所以,建议将PSP用作任务栈指针,使得任务栈和系统的异常处理栈空间分开。当然了,这样也会让任务运行于用户级模式。
如果你一定要使用MSP的话,也是可以的。

任务参数为什么要设置成0x11111111

Q:任务的参数,我们任务一设置的0x11111111,另一个设置的0x22222222,我是没有看出来设置成什么数有什么说法,任意的?
A:随意设置的数据。当执行该任务的时候,可以通过观察任务的入口参数值是不是这些值来判断入口参数值是否传递成功。

void (entry)(void )的含义

这是一个返回值为void 参数列表为void *的函数指针。 在调用tTaskInit时,会将相应的任务定义函数,传入到entry里面。