深挖FreeRTOS内核实现(二)

前言:

在之前的学习中,我们知道在裸机系统中,全局变量、局部变量、子函数、中断发生函数的返回地址都是放在 RAM 中一个叫栈的空间中(栈是单片机 RAM 里面一段连续的内存空间,栈的大小一般在启动文件或者链接脚本里面指定,最后由 C 库函数 _main 进行初始化)。

而在 FreeRTOS 系统中,每个任务都是独立的,互不干扰的,那自然也要为每个任务都分配独立的栈空间。这个栈空间通常是一个预先定义好的全局数组,也可以是动态分配的一段内存空间,但它们都存在于 RAM 中。

FreeRTOS 中,任务的创建有两种方法,一种是使用动态创建,一种是使用静态创建。动态创建时,任务控制块和栈的内存是创建任务时动态分配的,任务删除时,内存可以释放。静态创建时:任务控制块和栈的内存需要事先定义好,是静态的内存,任务删除时 , 内 存不能释 放 。本节学习的静态创建

1.实现两个任务轮流切换:

(1)创建任务

首先我们要实现两个变量按照一定的频率轮流的翻转,每个变量对应一个任务,那么就需要定义两个任务栈,代码如下:

main.c:

c 复制代码
portCHAR flag1;
portCHAR flag2;

/*定义任务的栈,StackType_t就是uint32_t*/
#define TASK1_STACK_SIZE 20
StackType_t Task1_Stack[TASK1_STACK_SIZE];


#define TASK2_STACK_SIZE 20
StackType_t Task2_Stack[TASK2_STACK_SIZE];

接着是创建两个任务:

c 复制代码
/*创建任务1,目前没有参数传进来,不过后面会用到先定义一个void*指针*/
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);
	}
}

(2)定义任务控制块

既然创建了任务,那自然也要给每个任务创建一个TCB用来描述该任务的信息,我们在另外一个文件定义该TCB结构体:

FreeRTOS.h

c 复制代码
/*StackType_t就是uint32_t*/
typedef struct tskTaskControlBlock
{
	volatile StackType_t    *pxTopOfStack;    /* 栈顶 */

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

(1):栈顶指针,作为 TCB 的第一个成员。

(2):任务节点,这是一个内置在 TCB 控制块中的链表节点,通过这个节点,可以将任务控制块挂接到各种链表中。这个节点就是我们之前学习链表中的node,可以把这个typedef struct tskTaskControlBlock理解成struct person persons;

(3):任务栈起始地址。

(4):任务名称,字符串形式,长度由宏 configMAX_TASK_NAME_LEN来控制,该宏在 FreeRTOSConfig.h 中定义,默认为 16。

(5):数据类型重定义。

(*补充:我们知道节点结构体有一个成员void * pvOwner; 这个成员是void 指针,也就是能接收各种类型指针,所以我只要将该任务的TCB结构体首地址传给这个指针,就能访问该任务的全部成员,而后面我们将学习如何将TCB结构体首地址传给这个指针,这与我们上小节了解的宏定义有关)

整个main.c:

c 复制代码
portCHAR flag1;
portCHAR flag2;

/*定义任务的栈,StackType_t就是uint32_t*/
#define TASK1_STACK_SIZE 20
StackType_t Task1_Stack[TASK1_STACK_SIZE];
/*定义任务的TCB,用来描述该任务的信息*/
TCB_t Task1_TCB;

#define TASK2_STACK_SIZE 20
StackType_t Task2_Stack[TASK2_STACK_SIZE];
/*定义任务的TCB,用来描述该任务的信息*/
TCB_t Task2_TCB;

/*声明这两个任务*/
void Task1_Entry(void* p_arg);
void Task2_Entry(void* p_arg);

int main(void)
{

	for(;;)
	{
		
	}

}


/* 软件延时 */
void delay (uint32_t count)
{
	for(; count!=0; count--);
}

/*创建任务1,目前没有参数传进来,不过后面会用到先定义一个void*指针*/
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)实现任务创建函数

任务的栈,任务的函数实体,任务的控制块最终需要联系起来才能由系统进行统一调度。那么这个联系的工作就由任务创建函数 xTaskCreateStatic()来实现,所以我们要创建一个task文件来定义该函数:

task.c:

c 复制代码
#include "FreeRTOS.h"
#include "task.h"


/*
*************************************************************************
*                               函数声明
*************************************************************************
*/

static void prvInitialiseNewTask( 	TaskFunction_t pxTaskCode,              /* 任务入口 */
									const char * const pcName,              /* 任务名称,字符串形式 */
									const uint32_t ulStackDepth,            /* 任务栈大小,单位为字 */
									void * const pvParameters,              /* 任务形参 */
									TaskHandle_t * const pxCreatedTask,     /* 任务句柄 */
									TCB_t *pxNewTCB );


/*
*************************************************************************
*                               宏定义
*************************************************************************
*/


/*
*************************************************************************
*                               静态任务创建函数
*************************************************************************
*/
#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 puxStackBuffer,  /* 任务栈起始地址 */
					            TCB_t * const pxTaskBuffer )         /* 任务控制块指针 */
{
	TCB_t *pxNewTCB;
	TaskHandle_t xReturn;

	if( ( pxTaskBuffer != NULL ) && ( puxStackBuffer != NULL ) )
	{		
		pxNewTCB = ( TCB_t * ) pxTaskBuffer; 
		pxNewTCB->pxStack = ( StackType_t * ) puxStackBuffer;

		/* 创建新的任务 */
		prvInitialiseNewTask( pxTaskCode,        /* 任务入口 */
                              pcName,            /* 任务名称,字符串形式 */
                              ulStackDepth,      /* 任务栈大小,单位为字 */ 
                              pvParameters,      /* 任务形参 */
                              &xReturn,          /* 任务句柄 */ 
                              pxNewTCB);         /* 任务控制块指针 */      

	}
	else
	{
		xReturn = NULL;
	}

	/* 返回任务句柄,如果任务创建成功,此时xReturn应该指向任务控制块 */
    return xReturn;
}

#endif /* configSUPPORT_STATIC_ALLOCATION */

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 );
	//pxTopOfStack = ( StackType_t * ) ( ( ( portPOINTER_SIZE_TYPE ) pxTopOfStack ) & ( ~( ( portPOINTER_SIZE_TYPE ) portBYTE_ALIGNMENT_MASK ) ) );
	/* 向下做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;
		}
	}
	/* 任务名字的长度不能超过configMAX_TASK_NAME_LEN */
	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;
	}
}

task.h:

c 复制代码
#ifndef INC_TASK_H
#define INC_TASK_H

#include "list.h"


#define taskYIELD()			portYIELD()

/* 任务句柄 */
typedef void * TaskHandle_t;


#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 puxStackBuffer,  /* 任务栈起始地址 */
					            TCB_t * const pxTaskBuffer )         /* 任务控制块指针 */ 
#endif /* configSUPPORT_STATIC_ALLOCATION */
                                
void prvInitialiseTaskLists( void );                                
void vTaskStartScheduler( void );
void vTaskSwitchContext( void );
                                
#endif /* INC_TASK_H */ 

configSUPPORT_STATIC_ALLOCATION 在 FreeRTOSConfig.h 中定义,我们配置为 1(即使用静态创建任务函数)

(3.1)分析xTaskCreateStatic()函数内部实现

下面我们来分析xTaskCreateStatic()函数,(1)~(6)代指该函数的参数

(1)任务入口,即任务的函数名称(也就是将我们创建的任务函数传进来),我们可以看到该pxTaskCode定义为TaskFunction_t类型,所以我们去看这个类型是怎么定义的:

c 复制代码
typedef void (*TaskFunction_t)( void * );

所以TaskFunction_t这个类型,对应的就是返回值为void,且带有一个void*参数的函数的指针

(2)任务名称,字符串形式,方便调试。

(3)任务栈大小,单位为字

(4)任务形参。

(5)任务栈起始地址。

(6)任务控制块指针。

接下来就分析该函数的内部:

红框1:首先定义一个TCB_t类型的结构体指针变量和TaskHandle_t(任务句柄)变量,该类型宏定义为:
c 复制代码
/* 任务句柄 */
typedef void * TaskHandle_t;

所以也就是一个void*的指针

红框2:当我们传进来的任务栈起始地址和TCB非空就进入该if()语句,接着就将我们传进来的TCB结构体传给pxNewTCB和将任务栈起始地址传给pxNewTCB的pxStack 成员
红框3:接着我们可以看到,它将这些变量都传给prvInitialiseNewTask()这个函数,那么我们进入到该函数内部看看:
蓝框1:定义了一个栈顶指针 pxTopOfStack(pxStack 记录的是栈空间的最低地址(栈底边界,固定不变);pxTopOfStack 的初始值是栈的最高地址(顶端边界),随压栈往低地址减小。)
蓝框2:首先将起始地址(pxNewTCB->pxStack + ( ulStackDepth - ( uint32_t ) 1 ))传给 pxTopOfStack。一开始我们传进来的 puxStackBuffer(栈的起始地址)是该栈的最低地址,所以我们要加上 ( ulStackDepth - ( uint32_t ) 1 ),让 pxStack 偏移到栈内存的最高地址。

打个比方就是:一开始我们定义一个 Task1_Stack[128] 的数组,而 puxStackBuffer = Task1_Stack[0],ulStackDepth = 128,pxStack + ( ulStackDepth - ( uint32_t ) 1 ) 就是 Task1_Stack[0 + 128 - 1] = Task1_Stack[127]

接着 pxTopOfStack = ( StackType_t * ) ( ( ( uint32_t ) pxTopOfStack ) & ( ~( ( uint32_t ) 0x0007 ) ) ) 的作用就是:向下做 8 字节对齐。

这就相当于十进制里"对齐到 100 的倍数":把后两位抹零。

复制代码
337 → 300    (337 不是 100 的倍数,抹掉零头 37)
300 → 300    (已经是 100 的倍数,不动)

& ~0x7 就是二进制版的"后三位抹零"。书里那个例子:33 抹掉低 3 位(001)→ 32,空出 1 个字节;如果地址是 36(低 3 位 100)→ 32,空出 4 个字节。空出的字节数 = 原地址除以 8 的余数。

蓝框3:就将pcName(任务名字)传进pxNewTCB结构体
蓝框4(重点):首先初始化节点,这不用多说,就是定义该节点不属于任何链表。接着就是 listSET_LIST_ITEM_OWNER,这个宏定义我们之前介绍过:
c 复制代码
/* 初始化节点的拥有者 */
#define listSET_LIST_ITEM_OWNER( pxListItem, pxOwner )		( ( pxListItem )->pvOwner = ( void * ) ( pxOwner ) )

listSET_LIST_ITEM_OWNER( &( pxNewTCB->xStateListItem ), pxNewTCB );

这一步就是将 pxNewTCB 这个结构体传给这个结构体的节点 的 pvOwner 成员。打个比方就是:我将我的个人的全部信息(pxNewTCB)输入到手机(xStateListItem)的备忘录中(pvOwner)。这样调度员就可以通过这个节点的pvOwner知道这个结构体的全部信息

蓝框5(重点):

pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters );

这一步我们就可以知道,pxPortInitialiseStack 把任务函数入口(pxTaskCode作为 PC)、形参(pvParameters作为R0)等寄存器初始值写进任务栈里(伪装成"任务已经跑过一次、现场被压栈"的样子),返回压完后的新栈顶,再赋给 TCB->pxTopOfStack。

可以看到 pxTopOfStack 不断 -1,按顺序把各个寄存器的初始值写入栈中对应位置,也就是压栈(从高到低),这些寄存器的功能我们之前了解过了。执行完就会返回pxTopOfStack,这时pxTopOfStack指向初始化栈帧下面的第一个空闲位置

最后就是判断我们要不要这个 pxCreatedTask(任务句柄)。如果有将 TaskHandle_t xReturn 传进来,那么它就会将该 TCB 传给 pxCreatedTask(任务句柄),它的用途是让后续 API(vTaskDelete、vTaskSuspend 这些)能找到对应任务的 TCB,从而操作这个任务。注意 pxCreatedTask 是一个二次指针,所以要先解引用。

红框4:就是将返回句柄

最后总结:xTaskCreateStatic() 的任务,就是把用户提供的任务信息整理成一份"任务档案":栈起始地址和任务名字直接存进 TCB;任务入口和形参压进任务栈(伪装成"已运行过、现场被压栈"的样子),栈大小则用来算出栈顶指针后存入 TCB;再让 TCB 内部的节点(xStateListItem)的 pvOwner 指向 TCB 首地址(相当于用手机备忘录保存我个人的全部信息);最后把这个 TCB 交给任务句柄------以后系统凭句柄就能找到并操控这个任务了。

(4)创建两个任务函数:

c 复制代码
#include "FreeRTOS.h"
#include "task.h"

portCHAR flag1;
portCHAR flag2;

/*定义任务的栈,StackType_t就是uint32_t*/
#define TASK1_STACK_SIZE 20
StackType_t Task1_Stack[TASK1_STACK_SIZE];
/*定义任务的TCB,用来描述该任务的信息*/
TCB_t Task1_TCB;

#define TASK2_STACK_SIZE 20
StackType_t Task2_Stack[TASK2_STACK_SIZE];
/*定义任务的TCB,用来描述该任务的信息*/
TCB_t Task2_TCB;

/*声明这两个任务*/
void Task1_Entry(void* p_arg);
void Task2_Entry(void* p_arg);

int main(void)
{
	xTaskCreateStatic(Task1_Entry,"Task1",TASK1_STACK_SIZE,NULL,Task1_Stack,&Task1_TCB);
	xTaskCreateStatic(Task2_Entry,"Task2",TASK1_STACK_SIZE,NULL,Task2_Stack,&Task2_TCB);
	for(;;)
	{
		
	}

}


/* 软件延时 */
void delay (uint32_t count)
{
	for(; count!=0; count--);
}

/*创建任务1,目前没有参数传进来,不过后面会用到先定义一个void*指针*/
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);
	}
}

2.就绪链表:

(1)初识就绪链表:

任务创建好之后,我们需要把任务添加到就绪链表里面,表示任务已经就绪,系统随时可以调度 。就绪链表在 task.c 中定义:

task.c:

c 复制代码
/* 任务就绪列表 */
List_t pxReadyTasksLists[ configMAX_PRIORITIES ];

就绪链表实际上就是一个 List_t 类型的数组,数组的大小由决定最 大 任 务 优 先 级 的 宏 configMAX_PRIORITIES 决 定 ,configMAX_PRIORITIES 在FreeRTOSConfig.h 中默认定义为 5,最大支持 256 个优先级。数组的下标对应了任务的优先级,同一优先级的任务统一插入到就绪列表的同一条链表中。一个空的就绪列表具体见图:

(2)就绪列表初始化:

就绪列表在使用前需要先初始化,就绪列表初始化的工作在函数 prvInitialiseTaskLists()里面实现:

task.c:

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

这段代码就是将链表数组里面的每个链表初始化

(3)将任务插入到就绪列表

任务控制块 TCB 里面有一个 xStateListItem 成员,数据类型为 ListItem_t。我们将任务插入到就绪列表的某个链表 里面,就是通过将任务控制块的 xStateListItem 这个节点插入到就绪列表中来实现的。

咱们用食堂排队打饭来打比方:

  • 就绪链表就像食堂里一条按顺序排好的打饭队伍,是个有序的队列。
  • 每个任务就像一个来打饭的人,是真正的主体,各自有自己的"事情"要完成。
  • TCB 里的 xStateListItem,就相当于每个人天生自带的左右手------它是每个人身体本身就有的一部分,不是额外拿的道具。
  • 把任务插入就绪列表,就相当于让一个人插进队伍的某个位置:他左手牵住前面人的衣角,右手递出去给后面的人牵着,自然而然就成了队伍里的一环。
  • 也正因为每个人都自带这双"手",同一个人可以从这支打饭队伍里退出来,转身用这同一双手,排到另一支队伍里(比如阻塞队列、延时队列),实现不同的状态切换。

代码如下:

c 复制代码
/*声明外部调用列表项*/
extern List_t pxReadyTasksLists[ configMAX_PRIORITIES ];

/*声明这两个任务*/
void Task1_Entry(void* p_arg);
void Task2_Entry(void* p_arg);

int main(void)
{
	/*初始化就绪列表*/
	prvInitialiseTaskLists();

	xTaskCreateStatic(Task1_Entry,"Task1",TASK1_STACK_SIZE,NULL,Task1_Stack,&Task1_TCB);
	/*将任务1的节点插入到就绪链表0中*/
	vListInsertEnd(&pxReadyTasksLists[0],&Task1_TCB.xStateListItem);

	xTaskCreateStatic(Task2_Entry,"Task2",TASK1_STACK_SIZE,NULL,Task2_Stack,&Task2_TCB);
	/*将任务2的节点插入到就绪链表1中*/
	vListInsertEnd(&pxReadyTasksLists[1],&Task2_TCB.xStateListItem);
	for(;;)
	{
		
	}

就绪列表的下标对应的是任务的优先级,但是目前我们的任务还不支持优先级,有关支持多优先级的知识点我们后面会讲到,所以 Task1 和 Task2 任务在插入到就绪列表的时候,可以随便选择插入的位置。

3.实现调度器:

调度器是操作系统的核心,其主要功能就是实现任务的切换,即从就绪列表里面找到优先级最高的任务,然后去执行该任务。从代码上来看,调度器无非也就是由几个全局变量和一些可以实现任务切换的函数组成,全部都在 task.c 文件中实现。

(1)启动调度器:

调度器的启动由 vTaskStartScheduler()函数来完成,该函数在 task.c 中定义:

c 复制代码
/* 当前正在运行的任务的任务控制块指针,默认初始化为NULL */
TCB_t * volatile pxCurrentTCB = NULL;

extern TCB_t Task1TCB;
extern TCB_t Task2TCB;
void vTaskStartScheduler( void )
{
    /* 手动指定第一个运行的任务 */
    pxCurrentTCB = &Task1TCB;
    
    /* 启动调度器 */
    if( xPortStartScheduler() != pdFALSE )
    {
        /* 调度器启动成功,则不会返回,即不会来到这里 */
    }
}

pxCurrentTCB:是一个在 task.c 中定义的全局指针,用于指向当前正在运行或者即将要运行的任务的任务控制块。目前我们还不支持优先级,所以手动指定第一个要运行的任务。因为任务随时会切换,所以我们要加 volatile,让 CPU 到 pxCurrentTCB 内存地址去读它的值。

(2)xPortStartScheduler()函数 :

xPortStartScheduler():调用函数 xPortStartScheduler()启动调度器,调度器启动成功,则不会返回。该函数在 port.c 中实现

c 复制代码
/* 
 * 参考资料《STM32F10xxx Cortex-M3 programming manual》4.4.3,百度搜索"PM0056"即可找到这个文档
 * 在Cortex-M中,内核外设SCB中SHPR3寄存器用于设置SysTick和PendSV的异常优先级
 * System handler priority register 3 (SCB_SHPR3) SCB_SHPR3:0xE000 ED20
 * Bits 31:24 PRI_15[7:0]: Priority of system handler 15, SysTick exception 
 * Bits 23:16 PRI_14[7:0]: Priority of system handler 14, PendSV 
 */
#define portNVIC_SYSPRI2_REG				( * ( ( volatile uint32_t * ) 0xe000ed20 ) )

#define portNVIC_PENDSV_PRI					( ( ( uint32_t ) configKERNEL_INTERRUPT_PRIORITY ) << 16UL )
#define portNVIC_SYSTICK_PRI				( ( ( uint32_t ) configKERNEL_INTERRUPT_PRIORITY ) << 24UL )

BaseType_t xPortStartScheduler( void )
{
    /* 配置PendSV 和 SysTick 的中断优先级为最低 */
	portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
	portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;

	/* 启动第一个任务,不再返回 */
	prvStartFirstTask();

	/* 不应该运行到这里 */
	return 0;
}

红框1:看注释我们就可以知道这里是操控内核外设SCB中SHPR3寄存器 来配置 PendSV 和 SysTick 的中断优先级为最低。SysTick 和

PendSV 都会涉及到系统调度,系统调度的优先级要低于系统的其它硬件中断优先级,即优先相应系统中的外部硬件中断,所以 SysTick 和 PendSV 的中断优先级配置为最低。寄存器图如下:

可以看到该寄存器的基地址就是0xe000ed20,而且bit24到bit31是控制SysTick,bit16到bit23是控制PendSV,可以看到bit16bit19和bit24bit27这 4 位是保留位(读为零),根本没实现。,所以我们只能操控bit20bit23和bit28bit31

红框2:调用函数 prvStartFirstTask()启动第一个任务,启动成功后,则不再返回,该函数由汇编编写,在 port.c 实现

(3) prvStartFirstTask()函数:

prvStartFirstTask()函数用于开始第一个任务,主要做了两个动作,一个是更新 MSP(主栈指针 ) 的值(栈顶,指向向量表的第一项),二是产生 SVC 系统调用,然后去到 SVC 的中断服务函数里面真正切换到第一个任务。

c 复制代码
/*
 * 参考资料《STM32F10xxx Cortex-M3 programming manual》4.4.3,百度搜索"PM0056"即可找到这个文档
 * 在Cortex-M中,内核外设SCB的地址范围为:0xE000ED00-0xE000ED3F
 * 0xE000ED008为SCB外设中SCB_VTOR这个寄存器的地址,里面存放的是向量表的起始地址,即MSP的地址
 */
__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
}

PRESERVE8:当前栈需按照 8字节对齐,如果都是 32 位的操作则 4 个字节对齐即可。在 Cortex-M 中浮点运算是 8 字节的。

在 Cortex-M 中,0xE000ED08 是 SCB_VTOR 寄存器的地址,VTOR 寄存器 → 向量表起始地址(表在哪),读该地址 → 向量表第一项 = 初始 MSP 值(表里装了什么)。向量表通常是从内部 FLASH 的起始地址开始存放,那么可知 memory(Flash):0x00000000(内核是固定访问 0x00000000 和 0x00000004 地址的,但实际上这两个地址可以被重映射到其它地址空间 。) 处存放的就是 MSP 的值。这个可以通过仿真时查看内存的值证实,如下图:

ldr r0, =0xE000ED08:将 0xE000ED08 这个立即数加载到寄存器 R0。

ldr r0, r0:将 0xE000ED08 这个地址指向的内容加载到寄存器 R0,此时 R0等于 SCB_VTOR 寄存器的值,等于 0x00000000,即 memory 的起始地址。

ldr r0, r0:将 0x00000000 (向量表起始地址 )这个地址指向的内容加载到 R0,此时 R0 等于0x200008DB(向量表第一项的内容,即栈顶地址)

msr msp, r0:将 R0 的值存储到 MSP,此时 MSP 等于 0x200008DB,这是主堆栈的栈顶指针。起始这一步操作有点多余,因为当系统启动的时候,执行完 Reset_Handler的时候,向量表已经初始化完毕,向量表第一项的值(初始 MSP),即指向主堆栈的栈顶指针。

总的来说就是:

"地址"和"地址处的内容"是两个东西:

0x00000000 ← 地址编号(门牌号),它本身只是个数字

0x200008DB ← 0x00000000 这个地址处存放的内容(房间里的东西)

ldr r0, [r0] 干的活就是"按门牌号找房间,把房间里的东西搬进 R0"。所以链条是:

0xE000ED08 地址处的内容 = 0x00000000(VTOR 的值 = 向量表起始地址)

0x00000000 地址处的内容 = 0x200008DB(向量表第一项 = 初始 MSP)

0x200008DB 地址处的内容 = 栈里的第一个字(将来第一个被压栈的数据)

每一层都是"地址 → 该地址处的内容",两条 ldr 连起来就是两级解引用。用 C 写出来就是:

c 复制代码
uint32_t *p = (uint32_t *)0xE000ED08;   // p 指向 VTOR 寄存器
uint32_t table = *p;                    // 第一次 ldr:table = 0x00000000
uint32_t *q = (uint32_t *)table;        // q 指向向量表
uint32_t sp = *q;                       // 第二次 ldr:sp = 0x200008DB

所以我们可以知道向量表的起始地址的值(栈顶指针)也是个地址,也就是说0x00000000是个二级指针

使能全局变量这步:使用 CPS 指令把全局中断打开。为了快速地开关中断, Cortex-M内核专门设置了一条 CPS 指令,有 4 种用法,具体见代码清单:

代码清单CPS 指令用法:

c 复制代码
CPSID I ;PRIMASK=1 ;关中断 
CPSIE I ;PRIMASK=0 ;开中断 
CPSID F ;FAULTMASK=1 ;关异常 
CPSIE F ;FAULTMASK=0 ;开异常 

PRIMASK 和 FAULTMAST 是 Cortex-M内核 里面三个中断屏蔽寄存器中的两个,还有一个是 BASEPRI,有关这三个寄存器的详细用法见表格:

调用SVC去启动第一个任务:

产生系统调用,服务号 0表示 SVC 中断,接下来将会执行 SVC 中断服务函数。

(4)vPortSVCHandler()函数 :

SVC 中断要想被成功响应,其函数名必须与向量表注册的名称一致,在启动文件的向量表中,SVC 的中断服务函数注册的名称SVC_Handler,所以 SVC 中断服务函数的名称我们应该写成 SVC_Handler,但是在 FreeRTOS 中,官方版本写的是 vPortSVCHandler(),为了能够顺利的响应 SVC 中断,我们有两个选择,改中断向量表中 SVC 的注册的函数名称或者改 FreeRTOS 中 SVC 的中断服务名称。这里,我们采取第二种方法,即在FreeRTOSConfig.h 中添加添加宏定义的方法来修改,具体见代码清单 7-20,顺便把PendSV 和 SysTick 的中断服务函数名也改成与向量表的一致。

c 复制代码
#define xPortPendSVHandler   PendSV_Handler
#define xPortSysTickHandler  SysTick_Handler
#define vPortSVCHandler      SVC_Handler

vPortSVCHandler()函数开始真正启动第一个任务,不再返回:

c 复制代码
__asm void vPortSVCHandler( void )
{
   extern pxCurrentTCB;
   
   PRESERVE8

	ldr	r3, =pxCurrentTCB	/* 加载pxCurrentTCB的地址到r3 */
	ldr r1, [r3]			/* 加载pxCurrentTCB到r1 */
	ldr r0, [r1]			/* 加载pxCurrentTCB指向的值到r0,目前r0的值等于第一个任务堆栈的栈顶 */
	ldmia r0!, {r4-r11}		/* 以r0为基地址,将栈里面的内容加载到r4~r11寄存器,同时r0会递增 */
	msr psp, r0				/* 将r0的值,即任务的栈指针更新到psp */
	isb
	mov r0, #0              /* 设置r0的值为0 */
	msr	basepri, r0         /* 设置basepri寄存器的值为0,即所有的中断都没有被屏蔽 */
	orr r14, #0xd           /* 当从SVC中断服务退出前,通过向r14寄存器最后4位按位或上0x0D,
                              使得硬件在退出时使用进程堆栈指针PSP完成出栈操作并返回后进入线程模式、返回Thumb状态 */
   
	bx r14                  /* 异常返回,这个时候栈中的剩下内容将会自动加载到CPU寄存器:
                              xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)
                              同时PSP的值也将更新,即指向任务栈的栈顶 */
}

extern pxCurrentTCB:声明外部变量 pxCurrentTCB,pxCurrentTCB 是一个在 task.c 中定义的全局指针,用于指向当前正在运行或者即将要运行的任务的任务控制块。

ldr r3, =pxCurrentTCB:加载 pxCurrentTCB 的地址到 r3

ldr r1, r3:加载 pxCurrentTCB 到 r3

ldr r0, r1:r1 里是 TCB 首地址,取其第一个成员(pxTopOfStack)放入 r0(作用:承载函数返回值和函数第一个入参,注意:这里第一个入参是在弹栈的情况下,而我们创建任务函数是伪压栈,所以r0是最后传参)------此时 r0 = 任务栈顶指针。一个刚刚被创建还没有运行过的任务的栈空间分布具体如图(即 r0 等于图的 该任务栈的pxTopOfStack):

ldmia r0!, {r4-r11}:以 r0 为基地址,将栈中向上增长的 8 个字的内容加载到 CPU 寄存器 r4~r11,同时 r0也会跟着自增。(也就是执行该任务)

msr psp, r0:将新的栈顶指针 r0 更新到 psp,任务执行的时候使用的堆栈指针是psp。此时 psp 指向该任务的栈顶(知识点补充:1.TCB的pxTopOfStack成员就是保存psp 2.PendSV 异常里做任务上下文切换,核心就是:把旧任务的寄存器压入当前 PSP 栈,保存 PSP 到旧 TCB;再从新 TCB 取出新的 PSP 值,恢复寄存器,退出异常后自动用新 PSP 运行新任务。)

mov r0, #0 :将寄存器 r0清 0

msr basepri, r0: 将 0 写入 BASEPRI 中断屏蔽寄存器,即打开所有中断。BASEPRI 的规则是:优先级数值 ≥ BASEPRI 值的中断都会被屏蔽;BASEPRI = 0 表示不屏蔽任何中断。

orr r14, #0xd:这是为"异常返回"做准备。Cortex-M 里异常返回不看普通地址,而是看 LR(r14)里的一个特殊值 EXC_RETURN(0xFFFFFFF1/F9/FD,高 28 位全1)------硬件认出这个值就知道"这是一次异常返回",并按它的低几位决定返回后的状态:

(当 r14 为 0xFFFFFFFX,执行的是中断返回指令,Cortex-M3 的做法,X 的 bit0 为 1 表示返回 Thumb 状态,bit1 和 bit2 分别表示返回后 SP 用 MSP 还是 PSP、以及返回到特权模式还是用户模式)

┌──────┬─────────────────────────────────────────────────┐

│ 位 │ 含义 │

├──────┼─────────────────────────────────────────────────┤

│ bit0 │ 保留,恒为 1 │

├──────┼─────────────────────────────────────────────────┤

│ bit1 │ 保留,恒为 0 │

├──────┼─────────────────────────────────────────────────┤

│ bit2 │ 返回后用哪个栈:0 = MSP,1 = PSP │

├──────┼─────────────────────────────────────────────────┤

│ bit3 │ 返回后的模式:0 = Handler 模式,1 = Thread 模式 │

└──────┴─────────────────────────────────────────────────┘

进入 SVC 异常时,硬件自动把 LR 设为 0xFFFFFFF9("回 Thread 模式、用 MSP")。执行 orr r14, #0xd 后变成 0xFFFFFFFD------bit2 置 1,其余不动。意思是:退出 SVC 后回到 Thread 模式(普通程序模式)、改用 PSP(任务栈)弹栈。

为什么:任务代码不是异常处理程序,必须在 Thread 模式跑;任务栈要用 PSP,不能占 MSP。

bx r14:跳到 EXC_RETURN 值,触发异常返回。此时硬件用 PSP 弹栈,自动把任务栈中剩下的内容装回 CPU 寄存器:R0(任务形参 pvParameters)、R1、R2、R3、R12、LR、PC(任务入口地址)、xPSR,同时PSP 更新为弹栈后的新位置------任务从此开始运行。

总结(重点):

执行第一个任务和实现轮流切换任务都离不开调度器:

(1)开启调度器 由 vTaskStartScheduler() 函数完成。该函数的作用:定义一个全局变量 pxCurrentTCB 用来手动指定第一个要执行的任务,接着调用 xPortStartScheduler() 函数。

(2)启动调度器 由 xPortStartScheduler() 函数完成。该函数的作用:配置 PendSV 和 SysTick 的中断优先级为最低(这两个中断是实现任务切换的关键,SysTick 的作用是控制多少时间触发一次中断,触发 SysTick 中断后,就会挂起 PendSV 异常 ,SysTick 退出时 PendSV 才执行------这正是它优先级设最低的意义,而 PendSV 的作用是实现任务上下文切换 ,所以这两个组合起来就能实现任务的轮流切换),然后调用 prvStartFirstTask() 函数来启动第一个任务。

(3)启动第一个任务 由 prvStartFirstTask() 函数完成,该函数由汇编语言编写。它的作用:一是更新 MSP(主栈指针 )的值=向量表第一项里存的内容(栈顶地址),二是产生 SVC 系统调用,然后去到 SVC 的中断服务函数里面真正切换到第一个任务。

补充说明 :SVC 是指一种内核异常源,它在向量表里占用一个固定位置,它由汇编指令触发,随后就会响应一个 SVC_Handler 异常服务函数,接着这个异常服务函数就会去执行第一个任务。这就类似于裸机中硬件触发中断,随后 CPU 就会跳转到该中断源对应的向量表的中断服务函数,复位时硬件会自动从向量表第一项取出栈顶地址加载到 MSP。

注意:SVC 的设计初衷,是为用户模式(非特权级)的代码提供一个受控通道进入特权级内核代码(ARM 架构背景)。不过 FreeRTOS 的任务默认运行在特权级,平时调内核 API 不经过SVC;本工程里我们只借用 SVC 异常来完成"启动第一个任务"这一次操作。。

(4)随后执行 vPortSVCHandler() 函数,该函数的作用就是弹栈 。它会通过任务 TCB,进入到该任务的栈(创建任务时栈顶存在 TCB->pxTopOfStack 里,此时 SVC_Handler 把它取出来赋给 PSP),取出该任务 TCB 的 pxTopOfStack(栈顶指针成员)( 即CPU会从 pxTopOfStack 指向的地址,从低地址往高地址自增,将任务栈的寄存器的值弹到 CPU,最后CPU的PC寄存器 被赋为任务入口地址,任务开始执行)。

5.任务切换:

任务切换就是在就绪列表中寻找优先级最高的就绪任务,然后去执行该任务。但是目前我们还不支持优先级,仅实现两个任务轮流切换,任务切换函数taskYIELD():

(1)taskYIELD()任务切换函数:

c 复制代码
#define taskYIELD()			portYIELD()

/* 中断控制状态寄存器:0xe000ed04
 * Bit 28 PENDSVSET: PendSV 悬起位
 */
#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 );											\
}

portYIELD 的实现很简单,实际就是将 PendSV 的悬起位置 1,当没有其它中断运行的时候响应 PendSV 中断,去执行我们写好的 PendSV中断服务函数,在里面实现任务切换。而控制该位的寄存器如下:

我们可以看看这个位对应的功能:

第 28 位 PENDSVSET:PendSV 挂起置位位

写入规则:

  • 0:无作用
  • 1:将 PendSV 异常状态置为挂起

读取规则:

  • 0:PendSV 异常未处于挂起状态
  • 1:PendSV 异常处于挂起状态

向该位写入 1,是将 PendSV 异常置为挂起状态的唯一方式。

之前我们了解到我们会xPortStartScheduler()函数,将PendSV中断优先级设置最低,所以如果在中断中将PendSV位置1,PendSV中断会在其它中断执行完后才会执行它对应的PendSV中断服务函数。

回忆 xPortStartScheduler 里的配置:SysTick 和 PendSV 写入的是同一个值 configKERNEL_INTERRUPT_PRIORITY,都是最低档。那 SysTick 为什么先跑?真实顺序链条是:

SysTick 先触发 → 进入 SysTick 服务函数 → 服务函数里才挂起 PendSV

→ 同优先级之间不能互相抢占 → PendSV 排队等着

→ SysTick 退出 → PendSV 才执行

所以得出结论"SysTick 先于 PendSV 执行",靠的是"先触发 + 同优先级不抢占",不是优先级高低。

(2)xPortPendSVHandler()(PendSV中断服务函数):

c 复制代码
void xPortPendSVHandler( void );

__asm void xPortPendSVHandler( void )
{
	extern pxCurrentTCB;
	extern vTaskSwitchContext;

	PRESERVE8

   /* 当进入PendSVC Handler时,上一个任务运行的环境即:
      xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)
      这些CPU寄存器的值会自动保存到任务的栈中,剩下的r4~r11需要手动保存 */
   /* 获取任务栈指针到r0 */
	mrs r0, psp
	isb

	ldr	r3, =pxCurrentTCB		/* 加载pxCurrentTCB的地址到r3 */
	ldr	r2, [r3]                /* 加载pxCurrentTCB到r2 */

	stmdb r0!, {r4-r11}			/* 将CPU寄存器r4~r11的值存储到r0指向的地址 */
	str r0, [r2]                /* 将任务栈的新的栈顶指针存储到当前任务TCB的第一个成员,即栈顶指针 */				
                              

	stmdb sp!, {r3, r14}        /* 将R3和R14临时压入堆栈,因为即将调用函数vTaskSwitchContext,
                                 调用函数时,返回地址自动保存到R14中,所以一旦调用发生,R14的值会被覆盖,因此需要入栈保护;
                                 R3保存的当前激活的任务TCB指针(pxCurrentTCB)地址,函数调用后会用到,因此也要入栈保护 */
	mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY    /* 进入临界段 */
	msr basepri, r0
	dsb
	isb
	bl vTaskSwitchContext       /* 调用函数vTaskSwitchContext,寻找新的任务运行,通过使变量pxCurrentTCB指向新的任务来实现任务切换 */ 
	mov r0, #0                  /* 退出临界段 */
	msr basepri, r0
	ldmia sp!, {r3, r14}        /* 恢复r3和r14 */

	ldr r1, [r3]
	ldr r0, [r1] 				/* 当前激活的任务TCB第一项保存了任务堆栈的栈顶,现在栈顶值存入R0*/
	ldmia r0!, {r4-r11}			/* 出栈 */
	msr psp, r0
	isb
	bx r14                      /* 异常发生时,R14中保存异常返回标志,包括返回后进入线程模式还是处理器模式、
                                  使用PSP堆栈指针还是MSP堆栈指针,当调用 bx r14指令后,硬件会知道要从异常返回,
                                  然后出栈,这个时候堆栈指针PSP已经指向了新任务堆栈的正确位置,
                                  当新任务的运行地址被出栈到PC寄存器后,新的任务也会被执行。*/
	nop
}

**extern pxCurrentTCB:**声明外部变量 pxCurrentTCB,pxCurrentTCB 是一个在 task.c 中定义的全局指针,用于指向当前正在运行或者即将要运行的任务的任务控制块。

**extern vTaskSwitchContext:**声明外部函数 vTaskSwitchContext(该函数就是将新任务的TCB传入到pxCurrentTCB,从而实现任务的切换),等下会用到。

**PRESERVE8:**本函数保持 AAPCS 的 8 字节栈对齐约定

mrs r0, psp: PSP 的值存储到 r0。当进入 PendSVC Handler 时,上一个任务运行的环境即: xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)这些 CPU 寄存器的值会自动存储到任务的栈中(注意: 这里写"PC(任务入口地址)、R0(任务的形参)"------这俩注释只对第一次调度成立!PendSV 切换时,任务 A 是运行到一半被打断的:压进去的 PC 是它被打断的位置(比如 delay 循环里某条指令),R0 是它当时的实际值(可能是个计数),跟任务入口、跟形参毫无关系。 ),剩下的 r4~r11 需要手动保存,同时PSP 会自动更新(在更新之前 PSP 指向任务栈的栈顶),此时 PSP 具体指向见图:

**ldr r3, =pxCurrentTCB:**加载 pxCurrentTCB 的地址到 r3。

**ldr r2, r3:**加载 r3 指向的内容到 r2------r2 = pxCurrentTCB 的值,即当前正在运行的任务(任务 A)的 TCB 地址。

**stmdb r0!, {r4-r11}:**以 r0 作为基址(指针先递减,再操作,STMDB 的 DB 表示 Decrease Befor),将 CPU 寄存器 r4~r11 的值存储到上一个任务栈,同时更新 r0的值,此时 r0 的指向具体见。

**str r0, r2:**将 r0 的值存储到 r2 指向的内容,r2 = pxCurrentTCB 的值,即当前正在运行的任务(任务 A)的 TCB 地址。具体为将r0 的值存储到上一个任务的栈顶指针 pxTopOfStack,具体指向上图的 r0 指向一样。到此,上下文切换中的上文保存就完成了。

对上面几步分析(重点):

这几步下来,可能有同学会感到疑惑,因为硬件压栈这一步它没有细说,或者根本没有说,下面我来画时间线具体描述一下:

任务A在跑(PSP 指它的栈顶)

│

▼ PendSV 异常来了

① 硬件自动压栈:xPSR、PC、LR、R12、R3~R0 → 8个字写进任务A的栈

同时 PSP 自动更新(减了32字节) 【硬件干的,发生在进 Handler 之前】

│

▼ 进入 PendSV_Handler

② mrs r0, psp → r0 = 任务A压完8字后的新栈顶 【只读,不压任何东西】

③ stmdb r0!, {r4-r11} → 手动把 r4~r11 压进任务A的栈 【这里才压】

④ str r0, r2 → 更新后的栈顶存回任务A的 TCB

(补充: 至此任务 A 的现场全部保存完毕(硬件压 8 个 + 手动压 8 个),下次轮到它时凭这份现场恢复。另外 portYIELD 宏里那对 __dsb / __isb 可:写 ICSR 后加指令屏障,确保"挂起 PendSV"这个动作立刻被内核看见,防止指令流水乱序导致切换延迟)

**stmdb sp!, {r3, r14}:**将 R3 和 R14 临时压入堆栈(在整个系统中,中断使用的是主堆栈,栈指针使用的是 MSP),因为接下来要调用函数 vTaskSwitchContext,调用函数时,bl 会把返回地址写进 R14(覆盖原值),所以一旦调用发生,R14 的值会被覆盖(PendSV 中断服务函数执行完毕后,返回的时候需要根据 R14 的值来决定返回处理器模式还是任务模式,出栈时使用的是 PSP 还是 MSP),因此需要入栈保护。R3 保存的是当前正在运行的任务(准确来说是上文,因为接下来即将要切换到新的任务)的 TCB 指针(pxCurrentTCB)地址,函数调用后 pxCurrentTCB 的值会被更新,后面我们还需要通过 R3 来操作 pxCurrentTCB,但是运行函数 vTaskSwitchContext 时不确定会不会使用 R3 寄存器作为中间变量,所以为了保险起见,R3 也入栈保护起来。( 这一步目的就是:先把 r3(&pxCurrentTCB 变量的地址,二级指针,这防止 r3 寄存器被 C函数覆盖后丢失变量地址,后面vTaskSwitchContext()修改的是pxCurrentTCB的值,也就是指向新任务TCB)和 r14(EXC_RETURN)压入主堆栈保护------前者是"邮箱地址",后者是"回家路线图",都怕被 C 函数破坏。然后关中断(进入临界段),调用vTaskSwitchContext():它不返回值,而是修改全局变量 pxCurrentTCB,让它指向新任务(如 Task2)的 TCB。开中断退出临界段后,恢复 r3 和 r14。)

*mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY / 进入临界段 */:**将 configMAX_SYSCALL_INTERRUPT_PRIORITY 的值存储到r0,该宏在 FreeRTOSConfig.h 中定义,用来配置中断屏蔽寄存器 BASEPRI 的值,高四位有效。目前配置为 191,因为是高四位有效,所以实际值等于 11,即优先级高于或者等于11 的中断都将被屏蔽。在关中断方面,FreeRTOS 与其它的 RTOS 关中断不同,而是操作BASEPRI 寄存器来预留一部分中断,并不像 μC/OS 或者 RT-Thread 那样直接操作PRIMASK 把所有中断都关闭掉(除了硬 FAULT)。

msr basepri, r0关中断,进入临界段,因为接下来要更新全局指针 pxCurrentTCB的值。

**bl vTaskSwitchContext:**调用函数 vTaskSwitchContext。该函数在 task.c 中定义,作用只有一个,选择优先级最高的任务,然后更新 pxCurrentTCB。目前我们还不支持优先级,则手动切换,不是任务 1 就是任务 2,该函数如下:

(3)vTaskSwitchContext()任务切换函数

c 复制代码
/*不按优先级,手动轮流切换任务*/
void vTaskSwitchContext( void )
{    
    /* 两个任务轮流切换 */
    if( pxCurrentTCB == &Task1TCB )
    {
        pxCurrentTCB = &Task2TCB;
    }
    else
    {
        pxCurrentTCB = &Task1TCB;
    }
}

**mov r0, #0 :**退出临界段

**msr basepri, r0:**开中断,直接往 BASEPRI 写 0。

**ldmia sp!, {r3, r14}:**从主堆栈中恢复寄存器 r3 和 r14 的值,此时的 sp 使用的是 MSP。

**ldr r1, r3:**加载 r3 指向的内容到 r1。r3 存放的是 pxCurrentTCB 的地址,即让 r1 等于 pxCurrentTCB。pxCurrentTCB的值在上面的 vTaskSwitchContext 函数中被更新,指向了下一个将要运行的任务的 TCB。

**ldr r0, r1:**加载 r1 指向的内容到 r0,即下一个要运行的任务的栈顶指针。

**ldmia r0!, {r4-r11}:**以 r0 作为基地址(先取值,再递增指针,LDMIA 的 IA 表示Increase After),将下一个要运行的任务的任务栈的内容加载到 CPU 寄存器 r4~r11。

**msr psp, r0:**更新 psp 的值,等下异常退出时,会以 psp 作为基地址,将任务栈中剩下的内容自动加载到 CPU 寄存器。

**bx r14 :**异常发生时,R14 中保存异常返回标志,包括返回后进入任务模式还是处理器模式、使用 PSP 堆栈指针还是 MSP 堆栈指针。此时的 r14 等于 0xfffffffd,表示异常返回后进入任务模式,SP 以 PSP 作为堆栈指针出栈,出栈完毕后 PSP 指向任务栈的栈顶 。当调用 bx r14 指令后,系统以 PSP 作为 SP 指针出栈,把接下来要运行的新任务的任务栈中剩下的内容加载到 CPU 寄存器:R0(任务形参)、R1、R2、R3、R12、R14(LR)、R15(PC)和 xPSR,从而切换到新的任务。

6.总结:

PendSV 的整个切换过程可以拆成一句话:存上文 → 换指针 → 取下文。

(1)存上文(保存任务 A 的现场)

PendSV 异常到来时,硬件先把 xPSR、PC、LR、R12、R3~R0 共 8 个寄存器自动压入任务 A 的栈,PSP 同步更新。进入 PendSV_Handler 后,软件再补两笔:stmdb r0!, {r4-r11} 手动压入剩下的 8 个寄存器(先减后存,向低地址生长),然后把更新后的栈顶 str r0, [r2] 存回任务 A 的 TCB->pxTopOfStack。至此任务 A 的全部 16 个寄存器现场都封存在它自己的栈里。

(2)换指针(切换"当前任务")

先把 r3(&pxCurrentTCB 变量的地址)和 r14(EXC_RETURN)压入主堆栈保护------前者是"邮箱地址",后者是"回家路线图",都怕被 C 函数破坏。然后关中断(进入临界段),调用 vTaskSwitchContext():它不返回值,而是修改全局变量 pxCurrentTCB,让它指向新任务(如 Task2)的 TCB。开中断退出临界段后,恢复 r3 和 r14。

(3)取下文(恢复任务 B 的现场)

用 r3 里的"邮箱地址"读出新的 pxCurrentTCB,取其第一个成员(栈顶指针)------这就是任务 B 上次被封存的位置。ldmia r0!, {r4-r11} 弹出任务 B 的 8 个寄存器(先取后增,向高地址回退)(手动弹栈 ),再把剩余栈顶 msr psp, r0 交给 PSP。最后 bx r14 触发异常返回:硬件按 EXC_RETURN 的指示回 Thread 模式、用 PSP 把剩下的 xPSR、PC、LR、R12、R3~R0 弹出(硬件自动弹栈)------PC 装上了任务 B 的断点地址,任务 B 从它上次被打断的地方继续跑。(可以发现这与上文的保存任务A现场的操作步骤顺序是相反的)

核心要点:
  1. 任务切换的本质就两个动作:pxCurrentTCB 指向新任务(换"当前任务"记录)+ PSP 指向新任务的栈(换运行环境),其余的压栈弹栈都是为这两个动作服务的。
  2. 16 个寄存器分两半保存:硬件自动压 8 个(进异常时)、软件手动压 8 个(r4~r11),恢复时顺序相反------先手动弹 8 个,异常返回时硬件弹剩下 8 个。
  3. 全程遵守满递减栈的规矩:压栈先减后存、弹栈先取后增,栈底边界(pxStack)不动,只有栈顶指针游走。
  4. 任务栈只装任务的现场;内核自己的临时数据(r3、r14)压主堆栈,各走各的栈。

实验:

(1)主程序代码:
c 复制代码
#include "FreeRTOS.h"
#include "task.h"

portCHAR flag1;
portCHAR flag2;

/*定义任务的栈,StackType_t就是uint32_t*/
#define TASK1_STACK_SIZE 20
StackType_t Task1_Stack[TASK1_STACK_SIZE];
/*定义任务的TCB,用来描述该任务的信息*/
TCB_t Task1TCB;

#define TASK2_STACK_SIZE 20
StackType_t Task2_Stack[TASK2_STACK_SIZE];
/*定义任务的TCB,用来描述该任务的信息*/
TCB_t Task2TCB;

/*声明外部调用列表项*/
extern List_t pxReadyTasksLists[ configMAX_PRIORITIES ];

/*声明这两个任务*/
void Task1_Entry(void* p_arg);
void Task2_Entry(void* p_arg);

int main(void)
{
	/*初始化就绪列表*/
	prvInitialiseTaskLists();

	xTaskCreateStatic(Task1_Entry,"Task1",TASK1_STACK_SIZE,NULL,Task1_Stack,&Task1TCB);
	/*将任务1的节点插入到就绪链表0中*/
	vListInsertEnd(&pxReadyTasksLists[0],&Task1TCB.xStateListItem);

	xTaskCreateStatic(Task2_Entry,"Task2",TASK1_STACK_SIZE,NULL,Task2_Stack,&Task2TCB);
	/*将任务2的节点插入到就绪链表1中*/
	vListInsertEnd(&pxReadyTasksLists[1],&Task2TCB.xStateListItem);
	
	/*开启调度器*/
	vTaskStartScheduler(); 
	for(;;)
	{
		
	}

}


/* 软件延时 */
void delay (uint32_t count)
{
	for(; count!=0; count--);
}

/*创建任务1,目前没有参数传进来,不过后面会用到先定义一个void*指针*/
void Task1_Entry(void* p_arg)
{
	for(;;)
	{
		flag1=1;
		flag2=0;
		/*切换任务*/
		taskYIELD();
	}
}

/*创建任务2*/
void Task2_Entry(void* p_arg)
{
	for(;;)
	{
		flag1=0;
		flag2=1;
		/*切换任务*/
		taskYIELD();
	}
}
(2)实验现象:

可以看到仿真示波器现象:两个任务实现了轮流切换

7.本章涉及到的汇编指令讲解

本章中有些函数是用汇编编写的,涉及到的 ARM 汇编指令具体参考表格:

其中重要指令:

其中重要指令,按掌握程度分两档:

第一档:能读懂 + 能讲出作用(就这几条)

指令 作用 出现在哪
mrs / msr 读 / 写特殊寄存器(PSP、MSP、BASEPRI) 取栈顶、切 PSP、临界段
stmdb r0!, {r4-r11} 压栈:先减后存 存上文
ldmia r0!, {r4-r11} 弹栈:先取后增 取下文
str / ldr 存 / 取内存 存栈顶回 TCB
bx r14 异常返回(按 EXC_RETURN) 收尾

第二档:看到认识就行 ------dsb/isb(屏障)、cpsie i(开中断)、orr(按位或)、svc(系统调用)、nop。

相关推荐
杨丰玮4181 小时前
C语言核心语法
java·c语言·开发语言·学习方法
这个人很懒67671 小时前
【无标题】
单片机·嵌入式硬件
ebiobiz1 小时前
Zig 工具链编译 STM32 开发指南
驱动开发·stm32·嵌入式硬件
Zwawa2 小时前
反馈:让系统知道自己做得对不对
c语言·单片机·嵌入式
燕落南枝2 小时前
c语言 洛谷P1765手机题解
c语言·开发语言
白色的北极熊2 小时前
c语言 求余运算符
c语言·数据结构·算法
小溪学编程3 小时前
C语言篇:枚举类型
java·c语言·前端
小溪学编程3 小时前
C语言篇:语言结构
c语言·开发语言·算法
老王爱玩车3 小时前
字符串和字符串函数
c语言·开发语言·数据结构·学习