一、什么是任务
在多任务系统中,我们根据功能的不同,把整个系统分割成多个独立且无法返回的函数,这种函数称之为任务。
二、创建任务
1.定义任务栈
在多任务系统中,每个任务都是独立的,相互之间都是不干扰的,所以要为每个任务都分配独立的栈空间,这个栈空间通常是一个预先定义好的数组,也可以是动态分配的一段内存空间,但它们都存在于RAM之中。本次实验验证会创建两个任务,因此需要定义两个任务栈(在main.c中定义),具体实现如下:
#define TASK1_STACK_SIZE 128
StackType_t Task1Stack[TASK1_STACK_SIZE];
#define TASK2_STACK_SIZE 128
StackType_t Task2Stack[TASK2_STACK_SIZE];
上面的代码本质上就是定义全局数组,StackType_t在portmacro中定义,实际为uint32_t类型。
2.创建任务函数
两个任务都是实现将变量按照一定的频率翻转,每个任务对应一个变量,代码实现如下(main.c中):
uint32_t flag1,flag2;
/* 软件延时 */
void delay(uint32_t count)
{
for(;count != 0;count --);
}
/* 任务1 */
void Task1_Entry(void *p_arg)
{
for(;;)
{
flag1 = 1;
delay(100);
flag1 = 0;
delay(100);
}
}
/* 任务2 */
void Task2_Entry(void *p_arg)
{
for(;;)
{
flag2 = 1;
delay(100);
flag2 = 0;
delay(100);
}
}
3.定义任务控制块
在裸机系统中,CPU是顺序执行程序的,而在多任务系统中,任务的执行是由系统调度的。系统为了顺利低调度任务,需要为每个任务额外地定义一个任务控制块,这个任务控制块相当于任务的身份证,里面存有任务的所有信息,比如任务的栈指针、任务名称、任务形参等。有了这个任务控制块之后,系统对任务的所有操作都是通过这个控制块来实现。定义一个任务控制块需要一种新的数据类型,这个数据类型在task.h中实现。具体实现如下:
typedef struct tskTaskControlBlock
{
volatile StackType_t *pxTopOfTack; /* 栈顶 */
ListItem_t xStateListItem; /* 任务节点 */
StackType_t *pxStack; /* 任务栈起始地址 */
char pcTaskName[configMax_TASK_NAME_LEN]; /* 任务名称,为字符串形式 */
}tskTCB;
typedef tskTCB TCB_t;
第一行定义了一个栈顶指针,并用volatile关键字修饰,volatile关键字的使用可参考这篇文章。
第二行定义了一个任务节点,ListItem_t 类型在list.h中实现。configMax_TASK_NAME_LEN是在FreeRTOSConfig中定义的一个宏定义,用来限制字符串的大小。
声明了上面的数据类型之后,在main.c文件中定义两个任务的控制块。
TCB_t Task1TCB;
TCB_t Task2TCB;
4.实现任务创建函数
任务的栈、任务的函数实体以及任务的控制块最终需要联系起来才能由系统进行统一的调度。这个联系的工作就是由任务创建函数xTaskCreateStatic()来完成,该函数在task.c定义。因此任务创建的过程其实就是将任务的栈、函数、控制块联系在一起。
(1)xTaskCreateStatic()函数实现
/* 任务创建函数(静态) */
#if (configSUPPORT_STATIC_ALLOCATION == 1)
TaskHandle_t xTaskCreateStatic(TaskFunction_t pxTaskCode, /* 任务入口 */
const char* const pcName, /* 任务名称,为字符串形式,方便调试 */
const uint32_t ulStackDepth, /* 任务栈大小,单位为字 */
void* const pvParameters, /* 任务形参 */
StackType_t* const puxTaskBuffer,/* 任务栈起始指针 */
TCB_t* const pxTaskBuffer) /* 任务栈控制指针 */
{
TCB_t* pxNewTCB;
TaskHandle_t xReturn;
if((pxTaskBuffer != NULL) && (puxTaskBuffer != NULL))
{
pxNewTCB = (TCB_t*)pxTaskBuffer;
pxNewTCB->pxStack = (StackType_t*)puxTaskBuffer;
/* 创建新的任务 */
prvInitialiseNewTask(pxTaskCode, /* 任务入口 */
pcName, /* 任务名称,字符串形式 */
ulStackDepth,/* 任务栈大小,单位为字 */
pvParameters,/* 任务形参 */
&xReturn, /* 任务句柄 */
pxNewTCB /* 任务栈起始地址 */
);
}
else
{
xReturn = NULL;
}
/* 返回任务句柄,如果任务创建成功,此时xReturn应指向任务控制块 */
return xReturn;
}
#endif
条件编译configSUPPORT_STATIC_ALLOCATION 在FreeRTOSConfig中设置,这里设置为1。在FreeRTOS中,任务的创建可以采用动态创建和静态创建两种方法。动态创建时,任务控制块和栈的内存是在创建任务时动态创建的,任务删除时,内存释放。静态创建时,任务控制块和栈的内存需要事先定义好,是静态的内存,任务删除时内存不能释放。目前以静态创建的方式实现(在嵌入式中似乎不建议用动态内存申请)。TaskFunction_t 是在projdefs.h中重定义的一个数据类型,这是void类型的指针?不理解!typedef void (*TaskFunction_t)(void*)
。
void指针的理解:
a. void指针是指针,也指向内存中某个地址的数据,但是内存中的数据类型是不确定的,所以使用时需要转换类型。
b. void的意思是无类型,是无类型指针,可以指向任何类型的数据。
因此void指针通常被称为通用指针或泛指针,或万能指针。(指针包含两个属性,一是指针的地址,这必不可少,而是指针对象的数据类型,若指针的对象的数据类型不明确或不用关心,则可设置为void,所以 void* 是只包含指向对象的地址的指针,这是个人的理解。)注意void指针和空指针是不一样的!!!
TaskHandle_t 是在task.h中定义的一个void的指针:typedef void* TaskHandle_t
。该函数返回一个TaskHandle_t类型的指针来指针任务控制块。
这个任务函数内部调用了另一个函数(prvInitialiseNewTask())来创建任务,其他代码很容易理解。
(2)prvInitialiseNewTask() 函数实现
这个函数在task.c文件中实现。
static void prvInitialiseNewTask(TaskFunction_t pxTaskCode,
const char* const pcName,
const uint32_t ulStackDepth,
void* const pvParameters,
TaskHandle_t* const pxCreatedTask, /* 任务句柄 */
TCB_t* pxNewTCB)
{
StackType_t* pxTopOfStack;
UBaseType_t x;
/* 获取栈顶地址 */
pxTopOfStack = pxNewTCB->pxStack + (ulStackDepth - (uint32_t)1); /* 栈的地址增长是由高到低的 */
/* 向下做8字节对齐 */
pxTopOfStack = (StackType_t*)(((uint32_t)pxTopOfStack) & (~((uint32_t)0x0007)));
/* 将任务名存储在TCB中 */
for(x = (UBaseType_t)0;x < (UBaseType_t)configMAX_TASK_NAME_LEN;x++)
{
pxNewTCB->pcTaskName[x] = pcName[x];
if(pcName[x] == 0x00)
break;
}
pxNewTCB->pcTaskName[configMAX_TASK_NAME_LEN-1] = '\0';
/* 初始化TCB中的xStateListItem节点 */
vListInitialiseItem(&(pxNewTCB->xStateListItem));
/* 设置xStateListItem节点的拥有者 */
listSET_LIST_ITEM_OWNER(&(pxNewTCB->xStateListItem),pxNewTCB);
/* 初始化任务栈 */
pxNewTCB->pxTopOfStack = pxPortInitialiseStack(pxTopOfStack,pxTaskCode,pvParameters);
/* 让任务句柄执行任务控制块 */
if((void*)pxCreatedTask != NULL)
{
*pxCreatedTask = (TaskHandle_t)pxNewTCB;
}
}
用 pxTopOfStack = pxNewTCB->pxStack + (ulStackDepth - (uint32_t)1) 这条语句来获取栈顶指针,即起始地址加上栈的大小再减1,关于栈的解释可参考这篇文章。用 pxTopOfStack = (StackType_t*)(((uint32_t)pxTopOfStack) & (~((uint32_t)0x0007))) 对内存向下做8字节对齐,有两个问题,为什么要对齐?为什么是8字节?第一个问题涉及到底层硬件电路实现或者说是处理器架构,大部分处理器不支持非对齐访问,原因在于非对齐访问的实现并不那么简单,会增加硬件电路的复杂性、增加访问时间等,总之,简单理解就是对齐访问更容易实现,最是一个取舍;如果是非对齐访问需要多次访存然后再进行数据拼接。第二个问题,8字节是为了兼容浮点运算。
不理解 vListInitialiseItem(&(pxNewTCB->xStateListItem)) 这个初始化有什么意义,下一条语句不是直接指定了 pxNewTCB->xStateListItem 的拥有者为 pxNewTCB 了?
用 pxPortInitialiseStack() 函数来初始化堆栈,因为初始化堆栈和处理器架构相关,该函数在portable\RVDS\ARM_CM4\port.c中实现,因为我最终会用STM32F4来验证,所以采用cortex-m4架构。
(3)pxPortInitialiseStack()函数的实现
static void prvTaskExitError(void)
{
for(;;);
}
StackType_t* pxPortInitialiseStack(StackType_t* pxTopOfStack,
TaskFunction_t pxCode,
void* pvParameters
)
{
/* 异常发生时,自动加载到CPU的内容 */
pxTopOfStack --;
*pxTopOfStack = portINITIAL_XPSR;
pxTopOfStack --;
*pxTopOfStack = ((StackType_t)pxCode) & portSTART_ADDRESS_MASK;
pxTopOfStack --;
*pxTopOfStack = (StackType_t)prvTaskExitError;
pxTopOfStack -= 5; /* r12、r3、r2和r1默认初始化为0 */
*pxTopOfStack = (StackType_t)pvParameters;
/* 异常发生时,手动加载到CPU的内容 */
pxTopOfStack -= 8;
/* 返回栈顶指针,此时pxTopOfStack指向空闲栈 */
return pxTopOfStack;
}
cortex-M内核有16个寄存器,包括通用寄存器R0-R12,栈指针R13,链接寄存器(LR)R14,程序计数器(PC)R15。上面的函数将寄存器组(除了R13)的值保存到栈中,初始化完成后的空间分布图如下:
上图来自野火的《FreeRTOS内核实现于应用开发实战指南》。
这样在进入异常处理函数后,手动加载R4~R11的数据到寄存器中,同时修改栈指针,当退出异常后,剩余的寄存器值由CPU自动加载。
三、实现就绪列表
1.定义就绪列表
任务创建好之后,还不能被CPU运行,需要将任务添加到就绪列表中,表示任务已经就绪,可以运行,系统随时可以调度。
/* 任务就绪列表 */
List_t pxReadyTasksLists[configMAX_PRIORITIES];
就绪列表实际上就是一个List_t类型的数组,数组的大小由最大任务优先级的宏configMAX_PRIORITIES来确定,configMAX_PRIORITIES在FreeRTOSConfig.h中默认定义为5,最大支持256个优先级。数组的下标对应任务的优先级,同一优先级插入到就序列表的同一个链表中。
2.就绪列表初始化
/* 就绪列表初始化 */
void prvInitialiseTaskLists(void)
{
UBaseType_t uxPriority;
foe(uxPriority = (UBaseType_t)0U;
uxPriority < (UBaseType_t)configMAX_PRIORITIES;
uxPriority++)
{
vListInitialise(&(pxReadyTasksLists[uxPriority]));
}
}
3.将任务插入就绪列表
下面的代码在main.c中实现:
/* 初始化就绪列表 */
prvInitialiseTaskLists();
/* 创建任务1 */
xTaskCreateStatic( (TaskFunction_t)Task1_Entry,
(char*)"Task1",
(uint32_t)TASK1_STACK_SIZE,
(void*)NULL,
(StackType_t)Task1Stack,
(TCB_t*)&Task1TCB);
/* 将任务添加到就绪列表 */
vListInsertEnd(&(pxReadyTasksLists[1]),&(((TCB_t*)(&Task1TCB))->xStateListItem));
/* 创建任务2 */
xTaskCreateStatic( (TaskFunction_t)Task2_Entry,
(char*)"Task2",
(uint32_t)TASK2_STACK_SIZE,
(void*)NULL,
(StackType_t)Task2Stack,
(TCB_t*)&Task2TCB);
/* 将任务添加到就绪列表 */
vListInsertEnd(&(pxReadyTasksLists[2]),&(((TCB_t*)(&Task2TCB))->xStateListItem));
四、实现调度器
调度器是操作系统的核心,其主要功能就是实现任务的切换,即从就绪列表里找到优先级最高的任务,然后执行任务。从代码上看,调度器由几个全局变量组成和一些可以实现任务切换的函数组成,全部在task.c中实现。
1.启动调度器
(1)vTaskStartScheduler()函数(task.c)
void vTaskStartScheduler(void)
{
/* 手动指定第一个任务 */
pxCurrentTCB = &Task1TCB;
/* 启动调度器 */
if(xPortStartScheduler() != pdFALSE)
{
/* 调度器启动成功则不会来到这里 */
}
}
pxCurrentTCB 是在task.c中定义的一个全局指针,用于指针当前正在运行的任务的控制块。由于目前还不支持优先级,所以这里手动指定第一个任务。然后调用xPortStartScheduler()来启动调度器,可以从函数名上看出,该函数的实现与具体的CPU架构有关,在port.c文件中实现。
(2)xPortStartScheduler()(port.c)
BaseType_t xPortStartScheduler(void)
{
/* 配置PendSV和SysTick的中断优先级最低 */
portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;
/* 启动第一个任务,不再返回 */
prvStartFirstTask();
return 0;
}
先设置PendSV和SysTick的中断优先级最低,这样做的目的是保证RTOS的实时性,然后调用prvStartFirstTask()来真正的启动第一个任务。
(3)prvStartFirstTask()
__asm void prvStartFirstTask(void)
{
PRESERVE8
/* 在cortex-M中,0xE000ED08是SCB_VTOR这个寄存器的地址,里面放的是向量表的向量表的起始地址,即msp的地址 */
ldr r0,=0xE000ED08
ldr r0,[r0]
ldr r0,[r0]
/* 设置主栈指针msp的值 */
msr msp,r0
/* 启用全局中断 */
cpsie i
cpsie f
dsb
isb
/* 调用SVC启动第一个任务 */
svc 0
nop
nop
}
从SCB_VTOR(地址为0xE000ED08)寄存器中读取中断向量表的起始地址(这里为什么要重映射不太理解),然后设置msp,即设置主栈的栈顶指针。在平常的裸机中只会用到msp,但在RTOS中还需要用到psp,在异常处理中使用msp,然后在任务中使用psp。接着开启全局中断,然后调用svc指令来产生中断,通过在中断处理函数中启动第一个任务。
(4)SVC_Handler()
SVC的中断服务函数名是固定的,但是可以通过#define来重命名,在FreeRTOS.h文件中重命名如下:#define vPortSVCHandler SVC_Handler
然后在port.c中实现vPortSVCHandler()函数:
__asm void vPortSVCHandler(void)
{
extern pxCurrentTCB;
PRESERVE8
ldr r3,=pxCurrentTCB
ldr r1,[r3]
ldr r0,[r1]
ldmia r0!,{r4-r11}
msr psp,r0
isb
mov r0,#0
msr basepri,r0
orr r14,#0xd
bx r14
}
用三条ldr指令加载栈顶指针到R0寄存器,然后从堆栈中读取R4到R11寄存器的值(其余寄存器的值CPU会自动加载),然后设置psp,开启所有中断。orr r14,#0xd这行代码不明白有什么意义。bx r14用这条指令跳转到R14寄存器指向的地址,在仿真的时候可以发现,在执行这条指令之前R14寄存器里的值并不是任务1函数入口地址,但是执行这条指令后能正确跳转到任务1函数中,原因在于执行bx指令后CPU先从栈中加载数据到R14中然后再跳转。
2.任务切换
上面已经启动了任务1,即执行vTaskStartScheduler()函数之后,就能跳转到任务1的函数中执行,现在需要CPU能够跳转到任务2执行。由于目前还不支持优先级,所以仅实现两个任务的切换就可以了。
(1)taskYIELD()
在task.h文件中对portYIELD进行重命名:#define taskYIELD() portYIELD()
在portmacro.h中对实现如下:
#define portNVIC_INT_CTRL_REG (*((volatile uint32_t*)0xe000ed04))
#define portNVIC_PENDSVSET_BIT (1UL << 28UL)
#define portSY_FULL_READ_WRITE (15)
#define portYIELD()\
{ \
/* 触发PendSV,产生上下文切换 */ \
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \
__dsb(portSY_FULL_READ_WRITE); \
__isb(portSY_FULL_READ_WRITE); \
}
任务切换的关键就是触发PendSV中断,在中断服务函数中进行任务切换。上述代码就是将SCB->ICSR寄存器的位28置1,这样就可以产生PendSV中断了。
(2)xPortPendSVHandler()
该函数是PendSV的中断服务函数(用#define 进行了重定义),是真正实现任务切换的函数。
__asm void xPortPendSVHandler(void)
{
extern pxCurrentTCB;
extern vTaskSwitchContext;
PRESERVE8
mrs r0,psp
isb
ldr r3,=pxCurrentTCB
ldr r2,[r3]
stmdb r0!,{r4-r11}
str r0,[r2]
/*** 到这里,上文保存号了 **/
stmdb sp!,{r3,r14}
mov r0,#configMAX_SYSCALL_INTERRUPT_PRIORITY
msr basepri,r0
dsb
isb
bl vTaskSwitchContext
mov r0,#0
msr basepri,r0
ldmia sp!,{r3,r14}
ldr r1,[r3]
ldr r0,[r1]
ldmia r0!,{r4-r11}
msr psp,r0
isb
bx r14
nop
}
这个函数的功能分为两大部分,前半部分是将上一个任务的相关变量入栈(R4R11,其余部分CPU会自动入栈),后半部分是任务切换和从栈中加载数据。其中用vTaskSwitchContext()函数来切换任务。第7行将psp的值存储到R0中,当进入PendSV时,上一个任务运行的环境即xPSR、PC、R14、R12、R3、R2、R1、R0,这些寄存器的值会自动地存储到任务地栈中,同时psp也会自动更新,剩余地R4R11需要手动入栈。第10行读取pxCurrentTCB这个变量的地址(注意是存储pxCurrentTCB这个变量的地址,而不是这个变量的值),然后第11行才是读取pxCurrentTCB这个变量的值。因此此时r2中的值为当前任务的控制块的起始地址。第13行以r0作为基址(指针先递减,再操作,STMDB中的DB表示Desrease Before),将寄存器r4~r11的值存储到任务栈,同时更新r0的值。第14行将r0的值(psp)存储到r2指向的内容,r2等于pxCurrentTCB。具体为将r0的值存储到上一个任务的栈顶指针pxTopOfStack。至此,上下文切换中的上文就保存好了。第16行将r3和r14入栈,因为在接下来会调用vTaskSwitchContext()函数,调用函数时,返回地址自动保存到r14中,所以一旦发生调用,r14的值就会被覆盖,因此需要入栈保护。r3保存的是当前正在运行的任务的TCB指针地址,函数调用后pxCurrentTCB的值会被更新,后面还需要通过r3来操作pxCurrentTCB,但是运行函数vTaskSwitchContext()是不确定会不会使用r3寄存器来存储中间变量,所以为了保险起见,将r3也入栈保护起来。第17、18行用来屏蔽中断。第21行调用vTaskSwitchContext()函数来切换任务,就在当前来说,无非就两个任务的切换,该函数的具体实现如下。第22、23行关闭中断,退出临界段。第24行从中主栈中恢复r3和r14的值。第26行加载r3指向的内容到r1。r3存放的是pxCurrentTCB的地址,即让r1等于pxCurrentTCB。pxCurrentTCB在上面的vTaskSwitchContext()函数中更新,指向了下一个要运行的任务的TCB。第27行加载r1指向的内容到r0,即下一个要运行任务的栈顶指针。第28行以r0作为基址(先取值,再递增指针,ldmia中的IA表示Increase After),将下一个要运行的任务的栈数据加载到r4~r11中。第29行更新psp的值,当异常退出时,剩余的数据会自动加载到寄存器中。调用bx指令退出异常,r14指定跳转地址(退出异常后,会从栈中加载函数入口地址到r14寄存器中),这样CPU就可以开始执行下一个任务了。
void vTaskSwitchContext(void)
{
/* 两个任务切换 */
if(pxCurrentTCB == &Task1TCB)
pxCurrentTCB = &Task2TCB;
else
pxCurrentTCB = &Task1TCB;
}
五、实验验证
在keil中单步调试观察寄存器值变化的时候发现一个问题:
可以看出任务1的栈空间的起始地址是0x20000010,而在main.c中定义了128个StackType_t类型的栈空间,每个StackType_t是四个字节,那么栈顶地址应该为:0x20000010+(128-1)*4 = 0x2000020C(注意是十六进制和十进制的相加,注意进制的转换),再进行向下的8字节对齐后的地址为:0x20000208。但是从keil得到的地址是0x20000250,多出来了(0x20000250-0x20000208)/4 = 18个栈空间。分析cortex-m4的栈空间分布,可以很快找到问题!!
可以看出从S0开始往上刚好有18个栈空间,和上面计算出来的一致,而上面多出来的是和FPU相关的,因此问题就在建立工程的时候选择了cortex-m4f,因此是开启了FPU的,问题就在这,重新建立一个工程,选择cortex-m4,栈顶的地址就正确了,如下图所示:
最后得到的波形如下,两个任务正常切换,实现了预期的功能。