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指针了,那么异常返回之后,程序就跳到任务函数的地方继续运行,那么就切到任务函数里了。