代码改变世界

freertos内核 任务定义与切换 原理分析

2022-01-28 22:01  jym蒟蒻  阅读(705)  评论(0编辑  收藏  举报

freertos内核 任务定义与切换 原理分析

    • 主程序
    • 任务控制块
    • 任务创建函数
    • 任务栈初始化
    • 就绪列表
    • 调度器
    • 总结任务切换

 

主程序

这个程序目的就是,使用freertos让两个任务不断切换。看两个任务中变量的变化情况(波形)。

下面这个图是任务函数里面delay(100)的结果。

在这里插入图片描述

下面这个图是任务函数里面delay(2)的结果.

在这里插入图片描述

多任务系统,CPU好像在同时做两件事,也就是说,最好预期就是,两变量的波形应该是完全相同的。

这个实验,delay减少了,他们两变量波形中间间距仍然没有减少,说明这个实验只是一个入门,远没达到RTOS的效能。

这个实验特点,就是具有任务主动切换能力,这是如何实现的呢,值得研究。

下面两个图,直观显示了程序的主动切换。观察CurrentTCB这个参数,可以发现它是一直变动的。

在这里插入图片描述

它究竟为什么变动呢,采用逐步debug的方式,可找到,是因为调用了一个SwitchContext函数。

在这里插入图片描述

那么先看一下main里面都有啥:

从下面可知,这里面有任务栈、任务控制块、有任务函数、还得创建任务。有就绪列表、有调度器。

任务栈:

#define TASK1_STACK_SIZE                    20
StackType_t Task1Stack[TASK1_STACK_SIZE];
#define TASK2_STACK_SIZE                    20
StackType_t Task2Stack[TASK2_STACK_SIZE];

任务函数(任务入口):

void Task1_Entry( void *p_arg )
{
	for( ;; )
	{
		flag1 = 1;
		delay( 100 );		
		flag1 = 0;
		delay( 100 );
		/* 任务切换,这里是手动切换 */
        taskYIELD();
	}
}
void Task2_Entry( void *p_arg )
{
	for( ;; )
	{
		flag2 = 1;
		delay( 100 );		
		flag2 = 0;
		delay( 100 );
		/* 任务切换,这里是手动切换 */
        taskYIELD();
	}
}

任务控制块:

TCB_t Task1TCB;
TCB_t Task2TCB;

就绪列表初始化:

prvInitialiseTaskLists();

创建任务:

typedef void * TaskHandle_t;
TaskHandle_t Task1_Handle;
Task1_Handle = 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 ) );

启动调度器:

vTaskStartScheduler(); 

任务控制块

多任务系统,任务执行由系统调度。任务的信息很多,于是就用任务控制块表示任务,这样方便系统调度。

任务控制块类型,包含了任务的所有信息,比如栈顶指针pxTopOfStack、任务节点xStateListItem、任务栈起始地址pxStack、任务名称pcTaskName。

typedef struct tskTaskControlBlock
{
	volatile StackType_t    *pxTopOfStack;    /* 栈顶 */

	ListItem_t			    xStateListItem;   /* 任务节点 */
    
    StackType_t             *pxStack;         /* 任务栈起始地址 */
	                                          /* 任务名称,字符串形式 */
	char                    pcTaskName[ configMAX_TASK_NAME_LEN ];  
} tskTCB;
typedef tskTCB TCB_t;

任务创建函数

main里面调用xTaskCreateStatic创建了任务,观察可知这个函数其实改变的是Task1TCB任务控制块,这个任务控制块诞生之初,就没有进行过初始化。调用任务创建函数目的就是初始化任务控制块。

Task1_Handle = xTaskCreateStatic( (TaskFunction_t)Task1_Entry,   /* 任务入口 */
					                  (char *)"Task1",               /* 任务名称,字符串形式 */
					                  (uint32_t)TASK1_STACK_SIZE ,   /* 任务栈大小,单位为字 */
					                  (void *) NULL,                 /* 任务形参 */
					                  (StackType_t *)Task1Stack,     /* 任务栈起始地址 */
					                  (TCB_t *)&Task1TCB );          /* 任务控制块 */

直观表述这个函数内部:

在这里插入图片描述

任务控制块里面的任务节点:下面代码是初始化过程,其实就是进行链表的普通节点初始化。

    /* 初始化TCB中的xStateListItem节点 */
    vListInitialiseItem( &( pxNewTCB->xStateListItem ) );
    /* 设置xStateListItem节点的拥有者 */
	listSET_LIST_ITEM_OWNER( &( pxNewTCB->xStateListItem ), pxNewTCB );

这个任务入口体现在哪呢,其实是体现在任务栈里面。在main.c里面初始化任务栈,仅仅开辟了一段内存空间,里面放什么东西都没有具体说明。调用任务创建函数之后,其实也一并初始化了任务栈(往里面放东西),任务入口就放到这个栈里了。任务栈也初始化完的时候,任务控制块才算圆满的初始化完了。

所以任务创建函数里面还得调用任务栈初始化函数。

任务栈初始化

初始化任务栈的函数代码在下面:

StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxCode, void *pvParameters )
{
    /* 异常发生时,自动加载到CPU寄存器的内容 */
	pxTopOfStack--;
	*pxTopOfStack = portINITIAL_XPSR;	                                    /* xPSR的bit24必须置1 */
	pxTopOfStack--;
	*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK;	/* PC,即任务入口函数 */
	pxTopOfStack--;
	*pxTopOfStack = ( StackType_t ) prvTaskExitError;	                    /* LR,函数返回地址 */
	pxTopOfStack -= 5;	/* R12, R3, R2 and R1 默认初始化为0 */
	*pxTopOfStack = ( StackType_t ) pvParameters;	                        /* R0,任务形参 */
    
    /* 异常发生时,手动加载到CPU寄存器的内容 */    
	pxTopOfStack -= 8;	/* R11, R10, R9, R8, R7, R6, R5 and R4默认初始化为0 */

	/* 返回栈顶指针,此时pxTopOfStack指向空闲栈 */
    return pxTopOfStack;
}
static void prvTaskExitError( void )
{
    /* 函数停止在这里 */
    for(;;);
}

栈顶指针就是pxTopOfStack。pxStack是一个指针指向任务栈起始地址,ulStackDepth是任务栈大小。下面是获取栈顶指针的代码。栈是后进先出,先进去的后出。其实也就是,先进栈的被压到最底下去了(下标最靠后)。所以,如果栈里面什么都没有,栈顶的位置得在最后面(也就是地址最高的哪个位置)。

/* 获取栈顶地址 */
pxTopOfStack = pxNewTCB->pxStack + ( ulStackDepth - ( uint32_t ) 1 );

下面两个图表述的都是一个意思,只不过右边的可能好懂点(先进栈的被压到最底下去了)。

在这里插入图片描述

初始化任务栈的函数运行完,栈就发生了变化,里面有内容了,如下图所示。可以看到任务入口地址存进去了,任务形参也存进去了。

#define portINITIAL_XPSR			        ( 0x01000000 )

在这里插入图片描述

至此,通过任务创建函数,已经圆满的初始好了任务控制块,同时填充了任务栈,任务栈联系了任务入口地址(任务的函数实体)。任务控制块成员变量里面有栈顶指针,联系了任务栈。那么,任务的栈、任务的函数实体、任务的控制块通过任务创建函数就联系起来了。

这里面插一句:任务栈一个元素占四个字节!上面那个图,如果r0地址是0x40,那么pxTopOfStack地址就是0x20(因为0x40-0x20=32),32÷4=8,也就是说八个元素。

#define portSTACK_TYPE	uint32_t
typedef portSTACK_TYPE StackType_t;
StackType_t Task1Stack[TASK1_STACK_SIZE];

uint32_t
u:代表 unsigned 即无符号,即定义的变量不能为负数;
int:代表类型为 int 整形;
32:代表四个字节,即为 int 类型;
_t:代表用 typedef 定义的;
整体代表:用 typedef 定义的无符号 int 型宏定义;
位(bit):每一位只有两种状态0或1。计算机能表示的最小数据单位。
字节(Byte):8位二进制数为一个字节。计算机基本存储单元内容用字节表示。

就绪列表

下面是main里面就绪列表的定义、初始化,添加任务到就绪列表。

首先绪列表的定义,简而言之,就绪列表是一个List_t类型的数组(其实数组中每个元素就相当于根节点),数组下标对应任务的优先级。

#define configMAX_PRIORITIES		            
/* 任务就绪列表 */
List_t pxReadyTasksLists[ configMAX_PRIORITIES ];
/* 初始化与任务相关的列表,如就绪列表 */
prvInitialiseTaskLists();
/* 将任务添加到就绪列表 */                                 
vListInsertEnd( &( pxReadyTasksLists[1] ), &( ((TCB_t *)(&Task1TCB))->xStateListItem ) );
/* 将任务添加到就绪列表 */                                 
vListInsertEnd( &( pxReadyTasksLists[2] ), &( ((TCB_t *)(&Task2TCB))->xStateListItem ) );

就绪列表初始化函数如下,简而言之,就是对List_t类型的数组里面每个元素进行初始化(根节点初始化)。

/* 初始化任务相关的列表 */
void prvInitialiseTaskLists( void )
{
    UBaseType_t uxPriority;
    
    for( uxPriority = ( UBaseType_t ) 0U; uxPriority < ( UBaseType_t ) configMAX_PRIORITIES; uxPriority++ )
	{
		vListInitialise( &( pxReadyTasksLists[ uxPriority ] ) );
	}
}

添加任务到就绪列表的函数是vListInsertEnd,这个在之前双向循环链表说过,其实就是把普通节点插到根节点后。

就绪列表在不同任务之间建立一种联系,图示如下。

在这里插入图片描述

调度器

启动调度器,是用了一个SVC中断。

从下面代码可以看出,pxCurrentTCB指向的是Task1TCB(任务控制块)的地址。

typedef struct tskTaskControlBlock
{
	volatile StackType_t    *pxTopOfStack;    /* 栈顶 */

	ListItem_t			    xStateListItem;   /* 任务节点 */
    
    StackType_t             *pxStack;         /* 任务栈起始地址 */
	                                          /* 任务名称,字符串形式 */
	char                    pcTaskName[ configMAX_TASK_NAME_LEN ];  
} tskTCB;
typedef tskTCB TCB_t;

//void vTaskStartScheduler( void )函数里
pxCurrentTCB = &Task1TCB;

下面这个svc的中断函数,里面第一步就是把任务栈的栈顶指针给r0寄存器。

可以认为:r0=pxTopOfStack(任务栈的栈顶指针的地址)。

//__asm void vPortSVCHandler( void )函数里
ldr	r3, =pxCurrentTCB	//加载pxCurrentTCB的地址到r3
ldr r1, [r3]			//把r3指向的内容给r1,内容就是Task1TCB的地址
ldr r0, [r1]  //把r1指向的内容给r0,内容就是Task1TCB的地址里面的第一个内容,也就是pxTopOfStack

接下来:以r0(任务栈的栈顶指针的地址)为基地址,将任务栈里面向上增长的8字节内容加载到CPU寄存器r4-r11。

ldmia r0!, {r4-r11}

然后将r0存到psp里。

msr psp, r0

下面这个代码,目的是改EXC_RETURN值为0xFFFFFFD,这样的话中断返回就进入线程模式,使用线程堆栈(sp=psp)。

orr r14, #0xd

看下面这个图,异常返回时,出栈用的是PSP指针。PSP指针把任务栈里面剩余的内容(没有读到寄存器里的内容)全部给弄出去(自动将栈中的剩余内容加载到cpu寄存器)。那么任务函数的地址就给到了PC,程序就跳到任务函数的地方继续运行。

在这里插入图片描述

图1如下:注意,动的是psp,pxTopOfStack是不动的。

在这里插入图片描述

下面是实验证明上面关于psp指针运动描述的正确性:

r0一开始存的就是pxTopOfStack的值(任务栈的栈顶指针的地址)

在这里插入图片描述

接下来把运动过的r0给psp,此时的psp位置就在图1psp2那个地方。

在这里插入图片描述

下图这个psp地址仍然是0x40。

在这里插入图片描述

程序运行完bx r14,就跑到任务函数里面了,此时的psp=0x60,位置就在图1的psp3。

在这里插入图片描述

现在程序跑到任务函数里面去了,任务函数里面调了taskYIELD()函数,目的就是触发PendSV中断(优先级最低,没有其他中断运行时才响应)。下面这个图是进到PendSV中断服务函数之前的寄存器组状态。

在这里插入图片描述

下面这个图是进到PendSV中断服务函数时的寄存器组状态。可以观察psp,从0x60变成了0x40。

在这里插入图片描述

现在psp的位置就可以知道了,如下图所示。这是因为,进到xPortPendSVHandler函数之后,上个任务运行的环境将会自动存储到任务的栈中,同时psp自动更新。

在这里插入图片描述

下面这个代码,把psp的值存到r0里面。

//__asm void xPortPendSVHandler( void )函数
mrs r0, psp
//void vTaskStartScheduler( void )函数里
pxCurrentTCB = &Task1TCB;/*pxCurrentTCB有一个地址,这个地址里面的内容是当前任务的地址*/
						  /*当前任务地址的第一个内容就是当前任务的栈顶指针*/

//__asm void xPortPendSVHandler( void )函数里
ldr	r3, =pxCurrentTCB		/* 加载pxCurrentTCB的地址到r3 */
ldr	r2, [r3]         /* 把r3指向的内容给r2,内容就是Task1TCB(当前任务)的地址*/
					  /*[r2]是当前任务栈的栈顶指针*/
stmdb r0!, {r4-r11}			/* 将CPU寄存器r4~r11的值存储到r0指向的地址 */
str r0, [r2]                /* 把r0的地址给当前任务栈的栈顶指针 */	

经过上面这个代码,现在r0的位置如下。psp在上面这个过程是没变化的,变的只有r0。

在这里插入图片描述

对照着下面这个图,更清晰点。r2存的是当前任务的地址。r0存的是栈顶指针的地址。

在这里插入图片描述

下面对r3进行说明:r3=0x2000000C,这个地址里面存的第一个内容是当前任务块的地址0x20000068如下图所示。

在这里插入图片描述

下面对当前任务块的地址进行说明:当前任务块的地址0x20000068里面存的第一个内容就是栈顶指针的地址。

在这里插入图片描述

下面对栈顶指针的地址进行说明:栈顶指针地址里面内容刚好就是当前任务的任务栈。

在这里插入图片描述

可以对比下图,观察当前任务栈里面的内容,与此同时内容也对应了地址,地址就可以通过上图推出,比如,0x20000060地址里面存的就是0x10000000。

在这里插入图片描述

下面这个代码:目的是将r3和r14临时压入主栈(MSP指向的栈),因为接下来需要调用任务切换函数,调用函数时,返回地址自动保存到r14里面。r3的内容是当前任务块的地址(ldr r3, =pxCurrentTCB),调用函数后,pxCurrentTCB会被更新。

stmdb sp!, {r3, r14}

执行代码之前,MSP指向0x20000058这个地址。

在这里插入图片描述

执行代码之后,MSP指向的地址少了8个字节,与此同时r3和r14存到了MSP指向的地址里面。

在这里插入图片描述

msp指向的栈里面的具体信息其实可以反推出来,如下绿字:

在这里插入图片描述

下面这个代码:basepri是中断屏蔽寄存器,下面这个设置,优先级大于等于11的中断都将被屏蔽。相当于关中断进入临界段。

mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY 
msr basepri, r0
/*
#define configMAX_SYSCALL_INTERRUPT_PRIORITY 	191   /* 高四位有效,即等于0xb0,或者是11 */
191转成二进制就是11000000,高四位就是1100
*/

下面这个代码:调用了函数vTaskSwitchContext,这个函数目的是选择优先级最高的任务,然后更新pxCurrentTCB。目前这里面使用的是手动切换。

bl vTaskSwitchContext 
void vTaskSwitchContext( void )
{    
    /* 两个任务轮流切换 */
    if( pxCurrentTCB == &Task1TCB )
    {
        pxCurrentTCB = &Task2TCB;
    }
    else
    {
        pxCurrentTCB = &Task1TCB;
    }
}

现在说明一下调用这个函数产生什么后果:

从下图可知,此时r3=0x2000000C,这个地址里面的的内容就是当前任务块的地址。

在这里插入图片描述

进行到下面这一步,当前任务块的地址变了,与此同时,0x2000000C地址里面的的内容也变了。也就是说,走出调用函数之后,通过r3就能找到变化后新的任务地址了。

那么此时豁然开朗,为什么调用函数前要把r3入栈呢,看下图正中间上方的汇编代码,这个c语言背后的汇编代码是调用寄存器r0、r1存一些中间变量,为了防止运行函数时往r3寄存器里面存中间变量,才把r3入栈保护起来。想一下,如果往r3寄存器里面存中间变量,那么0x2000000C地址就不存到r3寄存器里了,那也无法通过r3找到变化后新的任务地址了。

在这里插入图片描述

下面这个代码:优先级高于0的中断被屏蔽,相当于是开中断退出临界段。

mov r0, #0                  /* 退出临界段 */
msr basepri, r0

下面这个代码恢复r3和r14

ldmia sp!, {r3, r14}        /* 恢复r3和r14 */

如下图,r3和r14被恢复,而且MSP从0x20000550变成了0x20000558。

在这里插入图片描述

这里面有个细节,MSP变动之后,MSP指向的栈前面的数(存的r3和r14)却被留了下来。这让人不禁思考出栈究竟是什么意思,这里不就只是动了MSP指针吗。

此时观察psp地址里面的内容,可发现,还是之前的那个任务栈。看了出栈和c语言里面实体的出(c语言里面出栈后,出去的内容就不在栈里面了)还不太一样,这个出栈,动的是指针,内容还在栈里面。

在这里插入图片描述

下面这个代码,进行完,r0里面存的是当前任务栈的栈顶指针的地址。

ldr r1, [r3]
ldr r0, [r1] 				/* 当前激活的任务TCB第一项保存了任务堆栈的栈顶,现在栈顶值存入R0*/

在这里插入图片描述

下面是当前的任务栈里面的内容。

在这里插入图片描述

ldmia r0!, {r4-r11}			/* 出栈 */

这个时候r0位置变到了0x200000c0。

在这里插入图片描述

然后下面把r0给了psp。记得吧,之前psp指向的可是0x20000040,也就是上一个任务的任务栈,这里面切到了另一个任务的任务栈里面了。也就是psp指向0x200000c0。

msr psp, r0

下面这个代码运行完效果如下图。

bx r14  

仔细观察,异常退出时,会以psp作为基地址,将任务栈里面剩下的内容自动加载到CPU寄存器。然后PC指针就拿到了任务栈里面的任务函数地址,然后就跳到任务函数里了。至此,切换完成。

在这里插入图片描述

最后,观察一下psp:由下面两张图,就明白了,psp出栈是什么意思。

下面是返回Thread Mode后(进入到了任务函数里面)psp的指向。

在这里插入图片描述

下图是没有返回到Thread Mode时psp的指向。

在这里插入图片描述

总结任务切换

总结一下核心思路:

1.首先是这张图,在任务函数里面,处于Thread Mode状态(为什么呢,因为bx r14 指令,里面r14的值设置的是0xFFFFFFFD),然后通过任务函数里面的taskYIELD()函数,进入Handler Mode状态,里面进行了任务切换操作,就是说,psp指向的任务栈切换了(所以一会pc指向的任务函数也改了),然后结束异常的时候,psp出栈,pc现在指向的是切换后的任务函数地址,于是就又跳到另一个任务函数里。

在这里插入图片描述

2.要明白切到任务函数里面的原理

之前创建任务时,已经把任务函数保存在了任务栈内。

出栈的话,psp指向的栈里面剩下的东西,会加载到寄存器里面,如下图所示:那么任务函数地址就给到pc指针了,那么异常返回之后,程序就跳到任务函数的地方继续运行,那么就切到任务函数里了。

在这里插入图片描述