2. 任务
相关函数
1. xTaskCreateStatic()
2. prvInitialiseNewTask()
3. prvInitialiseTaskLists()
4. vTaskStartScheduler()
5. xPortStartScheduler()
6. prvStartFirstTask()
2.1 创建任务
2.1.1 定义任务栈
在裸机系统中,系统在运行时的全局变量、子函数调用时的局部变量、中断发生时函数的返回地址,都统统放在栈中,栈是单片机RAM中一段连续的内存空间。
但是在多任务系统中,每个任务都是独立的、互不干扰的,所以要为每个任务都分配独立的栈空间,这个栈空间通常是一个预先定义好的全局数组,也可以是动态分配的一段内存空间,他们都存在于RAM中
所以我们首先要定义任务栈,例如:
#define TASK1_STACK_SIZE 128
StackType_t Task1Stack[TASK1_STACK_SIZE]
#define TASK2_STACK_SIZE 128
StackType_t Task2Stack[TASK2_STACK_SIZE]
这里的128是128字,也就是512字节。其中的 StackType_t 是宏定义的重定义
#define portSTACK_TYPE uint32_t
typedef portSTACK_TYPE StackType_t;
2.1.2 定义任务函数
任务函数就是一个死循环,这个没什么可说的
2.1.3 定义任务控制块
在裸机系统中程序的主体是CPU按照顺序执行的,而在多任务系统中,任务的执行是由系统调度的。为了顺利地调度任务,为每个任务都额外定义了一个任务控制块,这个任务控制块相当于任务的身份证,里面存有任务的所有信息如:任务的栈指针、任务名称、任务的形参等
typedef struct tskTaskControlBlock
{
volatile StackType *pxTopOfStack; /* 栈顶 */
ListItem_t xStateListItem; /* 任务节点 */
UBaseType_t uxPriority; /* 任务优先级 */
StackType_t *pxStack; /* 任务栈起始地址 */
char pcTaskName[configMAX_TASK_NAME_LEN]; /* 任务名称 */
TickType_t xTicksToDelay; /* 挂起任务时间 */
}tskTCB;
typedef tskTCB TCB_t;
其中的任务节点是一个内置在TCB控制块中的链表节点,通过这个节点,可以将任务控制块挂接到各种链表中。TCB是件衣服,这个任务节点就是衣服上用来挂衣服的钩子。
ListItem_t是这样定义的:
struct xLIST_ITEM
{
TickType_t xItemValue; /* 辅助值,用来实现节点的顺序排列 */
struct xLIST_ITEM * pxNext; /* 指向下一个节点 */
struct xLIST_ITEM * pxPrevious; /* 指向前一个节点 */
void * pvOwner; /* 指向拥有该节点的内核对象 */
void * pvContainer; /* 指向该节点所在链表 */
};
typedef struct xLIST_ITEM ListItem_t
2.1.4 任务创建
任务的栈、任务五的函数实体以及函数的控制块最终需要联系起来才能由系统进行统一调度,这个联系的工作由任务创建函数xTaskCreateStatic()来完成。
2.1.4.1 xTaskCreateStatic() 创建任务
TaskHandle_t xTaskCreateStatic( TaskFunction_t pxTaskCode, /* 任务入口,即任务的函数名 */
const char * const pcName, /* 任务名称,字符串 */
const uint32_t ulStackDepth,/* 任务栈大小 ,单位为字 */
void * const pvParameters, /* 任务形参 */
UBaseType_t uxPriority,
StackType_t * const puxStackBuffer,/* 任务栈起始地址 */
StaticTask_t * const pxTaskBuffer ) /* 任务控制块指针 */
{
TCB_t *pxNewTCB;
TaskHandle_t xReturn;
if( ( pxTaskBuffer != NULL ) && ( puxStackBuffer != NULL ) )
{
/* The memory used for the task's TCB and stack are passed into this
function - use them. */
pxNewTCB = ( TCB_t * ) pxTaskBuffer; /*lint !e740 Unusual cast is ok as the structures are designed to have the same alignment, and the size is checked by an assert. */
pxNewTCB->pxStack = ( StackType_t * ) puxStackBuffer;
prvInitialiseNewTask( pxTaskCode, pcName, ulStackDepth, pvParameters, uxPriority, &xReturn, pxNewTCB, NULL );
}
else
{
xReturn = NULL;
}
/*返回任务句柄,如果prvInitialiseNewTask成功,那么这是xReturn应该指向任务控制块(TCB)*/
return xReturn;
}
这个函数干了这么几个活:
- 创建任务句柄
- 将任务控制块(TCB)的 任务栈起始地址指向我们定义的任务栈(全局数组)
- 调用函数prvInitialiseNewTask函数,初始化这个新任务
TaskFunction_t 是重定义的一个数据类型
typedef void (*TaskFunction_t)(void*);
这句话是什么意思呢,就是TaskFunction_t是一个指向函数的指针,这类函数具有void *类型的形参,返回值类型是void
typedef & 复杂的变量声明
理解复杂声明可用的“右左法则”:
从变量名看起,先往右,再往左,碰到一个圆括号就调转阅读的方向;括号内分析完就跳出括号,还是按先右后左的顺序,如此循环,直到整个声明分析完。举例:
int (func)(int p);
首 先找到变量名func,外面有一对圆括号,而且左边是一个号,这说明func是一个指针;然后跳出这个圆括号,先看右边,又遇到圆括号,这说明 (func)是一个函数,所以func是一个指向这类函数的指针,即函数指针,这类函数具有int类型的形参,返回值类型是int。
int (func[5])(int );
func 右边是一个[]运算符,说明func是具有5个元素的数组;func的左边有一个,说明func的元素是指针(注意这里的不是修饰func,而是修饰 func[5]的,原因是[]运算符优先级比高,func先跟[]结合)。跳出这个括号,看右边,又遇到圆括号,说明func数组的元素是函数类型的指 针,它指向的函数具有int类型的形参,返回值类型为int。
也可以记住2个模式:
type ()(…)函数指针
type (*)[]数组指针
TaskHandle_t :任务句柄
typedef void * TaskHandle_t
2.1.4.2 prvInitialiseNewTask() 创建新任务:
static void prvInitialiseNewTask( TaskFunction_t pxTaskCode,
const char * const pcName,
const uint32_t ulStackDepth,
void * const pvParameters,
UBaseType_t uxPriority,
TaskHandle_t * const pxCreatedTask,
TCB_t *pxNewTCB,
const MemoryRegion_t * const xRegions ) /*lint !e971 Unqualified char types are allowed for strings and single characters only. */
{
StackType_t *pxTopOfStack;
UBaseType_t x;
/* 获取栈顶地址 */
pxTopOfStack = pxNewTCB->pxStack + ( ulStackDepth - ( uint32_t ) 1 );
/* 向下做8字节对齐 */
pxTopofStack = ( StackType_t * )\
(((uint32_t)pxTopOfStack)&(~((uint32_t)0x0007)));
/* 将任务名存储在TCB中 */
/* Store the task name in the TCB. */
for( x = ( UBaseType_t ) 0; x < ( UBaseType_t ) configMAX_TASK_NAME_LEN; x++ )
{
pxNewTCB->pcTaskName[ x ] = pcName[ x ];
/* Don't copy all configMAX_TASK_NAME_LEN if the string is shorter than
configMAX_TASK_NAME_LEN characters just in case the memory after the
string is not accessible (extremely unlikely). */
if( pcName[ x ] == 0x00 )
{
break;
}
}
/* 任务名的妆度不能超过configMAX_TASK_NAME_LEN */
/* Ensure the name string is terminated in the case that the string length
was greater or equal to configMAX_TASK_NAME_LEN. */
pxNewTCB->pcTaskName[ configMAX_TASK_NAME_LEN - 1 ] = '\0';
/* 初始化TCB中的xStateListItem节点 */
vListInitialiseItem( &( pxNewTCB->xStateListItem ) );
/* 设置xStateListItem节点的拥有者 */
/* Set the pxNewTCB as a link back from the ListItem_t. This is so we can get
back to the containing TCB from a generic item in a list. */
listSET_LIST_ITEM_OWNER( &( pxNewTCB->xStateListItem ), pxNewTCB );
/* 初始化优先级 */
if( uxPriority >= ( UBaseType_t ) configMAX_PRIORITIES )
{
uxPriority = ( UBaseType_t ) configMAX_PRIORITIES - ( UBaseType_t ) 1U;
}
pxNewTCB->uxPriority = uxPriority;
/* 初始化任务栈 */
pxNewTCB->pxTopStack = pxPortInitialiseStack( pxTopOfStack,
pxTaskCode,
pvParameters);
/* 让任务句柄指向任务控制块 */
if( ( void * ) pxCreatedTask != NULL )
{
/* Pass the handle out in an anonymous way. The handle can be used to
change the created task's priority, delete the created task, etc.*/
*pxCreatedTask = ( TaskHandle_t ) pxNewTCB;
}
else
{
mtCOVERAGE_TEST_MARKER();
}
}
这个函数干的工作:
- 将TCB中的栈顶指针指向任务栈数组的最高位,作为栈顶
- 栈顶地址向下做8字节对齐
- 将TCB的内容填充,例如名字、xStateListItem节点的初始化以及设置拥有者、任务栈的初始化
- 调用pxPortInitialiseStack函数初始化任务栈
- 任务句柄指向任务控制块TCB
因为cortex-m系列的栈是由高到低使用的,所以我们要通过任务栈地址去找到栈顶地址,但是为什么栈顶地址要向下做8字节对齐呢?
对齐问题:无法回避的字节对齐问题,从八个方向深入探讨(变量对齐,栈对齐,DMA对齐,结构体成对齐,Cache, RTOS双堆栈等)_Simon223的博客-CSDN博客
当中用到了宏定义函数:
listSET_LIST_ITEM_OWNER
#define listSET_LIST_ITEM_OWNER( pxListItem, pxOwner)\
((pxListItem)->pvOwner = (void *) (pxOwner))
用到了前面的列表初始化函数vListInitialiseItem
2.1.4.3 pxPortInitialiseStack() 初始化栈
#define portINITAL_XPSR (0x01000000)
#define portSTART_ADDRESS_MASK ((StackType_t) 0xfffffffeUL)
static void prvTaskExitError(void)
{
/* 函数停在这里 */
for (;;);
}
StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxCode, void *pvParameters )
{
/* Simulate the stack frame as it would be created by a context switch
interrupt. */
/*添加偏移是为了说明 MCU 在中断进入/退出时使用堆栈的方式,并确保对齐*/
/* Offset added to account for the way the MCU uses the stack on entry/exit
of interrupts, and to ensure alignment. */
pxTopOfStack--;
*pxTopOfStack = portINITIAL_XPSR; /* xPSR */ [1]
pxTopOfStack--;
*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK; /* PC,任务函数的入口地址会保存到PC寄存器中 portSTART_ADDRESS_MASK = 0xfffffffe */
/* 任务的返回地址,通常任务是不会返回的,如果返回了就跳转到prvTaskExitError */
pxTopOfStack--;
*pxTopOfStack = ( StackType_t ) prvTaskExitError; /* LR */
/* 为R12, R3, R2 and R1保留位置 */
/* Save code space by skipping register initialisation. */
pxTopOfStack -= 5; /* R12, R3, R2 and R1. */
*pxTopOfStack = ( StackType_t ) pvParameters; /* R0 */
/* 异常发生时,手动加载到CPU寄存器的内容 */
pxTopOfStack -= 8; /* R11, R10, R9, R8, R7, R6, R5 and R4. */
/* 返回栈顶指针,此时pxTopOfStack指向空闲栈 */
return pxTopOfStack;
}
此时任务栈中的布局如下:
2.1.4.3.1 Cortex-M 中断行为
这一部分的内容参考的是Cortex-M权威指南中的“中断的具体行为”部分
在CM3开始想相应一个中断时会进行三步:
- 入栈:把8个寄存器的值压入栈
- 取向量:从向量表中找出对应的服务程序入口地址
- 选择堆栈指针MSP/PSP,更新堆栈指针SP,更新链接寄存器LR,更新程序计数器PC
入栈:
响应异常的第一个行动,就是自动保存现场的必要部分:依次把xPSR, PC, LR, R12以及R3‐R0由硬件自动压入适当的堆栈中:如果当响应异常时,当前的代码正在使用PSP,则压入PSP,即使用线程堆栈;否则压入MSP,使用主堆栈。一旦进入了服务例程,就将一直使用主堆栈。 依次入栈并不是排队入栈,先进的在里面后进的在外面。这种入栈在机器的内部,并不是严格按堆栈操作的顺序的——但是机器会保证:正确的寄存器将被保存到正确的位置 位置如下:
取向量
当数据总线(系统总线)正在为入栈操作而忙得团团转时,指令总线(I‐Code总线)可不是凉快地坐着看热闹——它正在为响应中断紧张有序地执行另一项重要的任务:从向量表中找出正确的异常向量,然后在服务程序的入口处预取指。由此可以看到各自都有专用总线的好处:入栈与取指这两个工作能同时进行。
更新寄存器
在入栈和取向量的工作都完毕之后,执行服务例程之前,还要更新一系列的寄存器:
- SP:在入栈中会把堆栈指针(PSP或MSP)更新到新的位置。在执行服务例程后,将由MSP负责对堆栈的访问。
- PSR:IPSR位段(地处PSR的最低部分)会被更新为新响应的异常编号。
- PC:在向量取出完毕后,PC将指向服务例程的入口地址,
- LR:LR的用法将被重新解释,其值也被更新成一种特殊的值,称为“EXC_RETURN”,并且在异常返回时使用。EXC_RETURN的二进制值除了最低4位外全为1,而其最低4位则有另外的含义。
知道CM3的中断行为以后我们就可以比较清晰的理解任务栈的初始化到底在做些什么了:
任务第一次运行时从这个栈指针开始手动加载8个字的内容到CPU寄存器:r4,r5,r6,r7,r8,r9,r10和r11. 当退出异常时,栈中剩下的8个字的内容会自动加载到CPU寄存器:r0,r1,r2,r3,r12,r14,r15和xPSR的位24.此时PC指针就指向了任务的入口地址,从而跳转到第一个任务,当然这还需要我们在开始第一个任务的时候进行相应的处理。
2.2 任务调度
任务的调度需要调度器来进行,它是操作系统的核心其主要功能就是实现任务的切换,即从就绪列表中找到优先级最高的任务,然后执行该任务。
这里提到了一个就绪列表,我们先来实现它。
2.2.1 就绪列表
任务创建好之后我们需要把任务添加到就绪列表中,表示任务已经就绪,系统随时可以调度。自然,就绪列表是一个全局变量,在task.c中定义
List_t pxReadyTasksLists[ configMAX_PRIORITIES ];
configMAX_PRIORITIES在FreeRTOSConfig.h中默认定义为5,最大支持256个优先级,数组的下标对应任务的优先级,同一优先级的任务同一插入就绪列表的同一条链表中。
2.2.1.1 初始化就绪列表
static void prvInitialiseTaskLists( void )
{
UBaseType_t uxPriority;
for( uxPriority = ( UBaseType_t ) 0U; uxPriority < ( UBaseType_t ) configMAX_PRIORITIES; uxPriority++ )
{
vListInitialise( &( pxReadyTasksLists[ uxPriority ] ) );
}
vListInitialise( &xDelayedTaskList1 );
vListInitialise( &xDelayedTaskList2 );
/* 任务延时部分(后面会提到) */
/* Start with pxDelayedTaskList using list1 and the pxOverflowDelayedTaskList
using list2. */
pxDelayedTaskList = &xDelayedTaskList1;
pxOverflowDelayedTaskList = &xDelayedTaskList2;
}
2.2.1.2 将任务插入就绪列表
就绪列表初始化之后用vTaskInsertEnd将任务添加到对应列表中即可
2.2.2 实现调度器
从代码上看,调度器是由几个全局变量和一些可以实现任务切换的函数组成的,在task.c中实现
2.2.2.1 CM3中如何实现任务切换
2.2.2.1.1 认识CM3中的特权等级与操作模式
CM3有两种操作特权等级:
-
特权级
在特权级下,可以访问所有的寄存器和所有的资源
-
非特权级(用户级)
在非特权级下有些寄存器是不能访问的,例如不能写特殊功能寄存器和nvic 终端控制寄存器
在特权级下的程序可以为所欲为,但也可能会把自己给玩进去——切换到用户级。一旦进入用
户级,再想回来就得走“法律程序”了——用户级的程序不能简简单单地试图改写 CONTROL 寄存器
就回到特权级,它必须先“申诉”:执行一条系统调用指令(SVC)。这会触发 SVC 异常,然后由异常
服务例程(通常是操作系统的一部分)接管,如果批准了进入,则异常服务例程修改 CONTROL 寄存
器,才能在用户级的线程模式下重新进入特权级。
事实上,从用户级到特权级的唯一途径就是异常:如果在程序执行过程中触发了一个异常,处
理器总是先切换入特权级,并且在异常服务例程执行完毕退出时,返回先前的状态(也可以手工指
定返回的状态).
CM3有两种操作模式
-
Handler模式
当执行一个异常处理或者执行一个中断服务历程的时候会进入handler模式。在Handle模式下,CPU始终处于特权级别,不可以处于非特权级(用户级)
-
Thread(线程)模式。
非Handler模式,就是线程模式。在线程模式下cpu既可以处于特权级,也可以处于非特权级模式
各状态和模式及等级间的切换
如何从特权级到非特权级:
- 通过修改control寄存器的第0位
- 在用户级的状态下是无法修改control寄存器的,因此在用户级下无法通过修改特殊寄存器而进入特权级,此时唯一的办法就是触发一个软中断通过软中断来进入Handler模式,同时使cpu转入特权级
2.2.2.1.2 控制寄存器
****
第0位nPRV位:表示当前的特权级别 :特权级和非特特权级。1为非特权级(用户级),0为特权级。仅当在特权级下操作时才允许写该位。一旦进入了用户级,唯一返回特权级的途径,就是触发一个(软)中断,再由服务例程改写该位
第1位 SPSEL位:代表堆栈指针的存储位置选择,选择是存在MSP还是PSP中
仅当处于特权级的线程模式下,此位才可写,其它场合下禁止写此位。改变处理器的模式也有其它的方式:在异常返回时,通过修改 LR 的位 2,也能实现模式切换。这是 LR 在异常返回时的特殊用法,颠覆了对 LR 的传统使用方式
2.2.2.1.3 两个堆栈指针MSP与PSP
CM3 的堆栈是分为两个:主堆栈和进程堆栈,CONTROL[1]决定如何选择。当 CONTROL[1]=0 时,只使用 MSP,此时用户程序和异常 handler 共享同一个堆栈。这也是复位后的缺省使用方式
**当 CONTROL[1]=1 时,线程模式将不再使用 MSP,而改用 PSP(handler 模式永远使用 MSP)。**这
样做的好处:
在使用 OS 的环境下,只要 OS 内核仅在 handler 模式下执行,用户应用程序仅在用户模式下执行,这种双堆栈机制派上了用场——防止用户程序的堆栈错误破坏 OS 使用的堆栈
注:此时,进入异常时的自动压栈使用的是进程堆栈,进入异常 handler 后才自动改为 MSP,退出异常时切换回PSP,并且从进程堆栈上弹出数据。
在特权级下,可以指定具体的堆栈指针,而不受当前使用堆栈的限制,示例代码如下:
MRS R0, MSP ; 读取主堆栈指针到 R0
MSR MSP, R0 ; 写 R0 的值到主堆栈中
MRS R0, PSP ; 读取进程堆栈指针到 R0
MSR PSP, R0 ; 写 R0 的值到进程堆栈中
通过读取 PSP 的值,OS 就能够获取用户应用程序使用的堆栈,进一步地就知道了在发生异常时,被压入寄存器的内容,而且还可以把其它寄存器进一步压栈。OS 还可以修改 PSP,用于实现多任务中的任务上下文切换。
2.2.2.1.4 认识SVC和PendVC
SVC介绍:
SVC(系统服务调用,亦简称系统调用)和 PendSV(可悬起系统调用),它们多用在上了操作系统的软件开发中。SVC 用于产生系统函数的调用请求。例如,操作系统通常不让用户程序直接访问硬件,而是通过提供一些系统服务函数,让用户程序使用 SVC 发出对系统服务函数的呼叫请求,以这种方法调用它们来间接访问硬件。因此,当用户程序想要控制特定的硬件时,它就要产生一个SVC 异常,然后操作系统提供的 SVC 异常服务例程得到执行,它再调用相关的操作系统函数,后者完成用户程序请求的服务。
SVC 异常通过执行”SVC”指令来产生。该指令需要一个立即数,充当系统调用代号。SVC 异常服务例程稍后会提取出此代号,从而获知本次调用的具体要求,再调用相应的服务函数。例如
SVC 0x3 ; 调用 3 号系统服务
在 SVC 服务例程执行后,上次执行的 SVC 指令地址可以根据自动入栈的返回地址计算出。找到了 SVC 指令后,就可以读取该 SVC 指令的机器码,从机器码中萃取出立即数,就获知了请求执行的功能代号。
PendSV介绍:
SVC 异常是必须在执行 SVC 指令后立即得到响应的(对于 SVC 异常来说,若因优先级不比当前正处理的高,或是其它原因使之无法立即响应,将上访成硬 fault),应用程序执行 SVC 时都是希望所需的请求立即得到响应。
PendSV 则不同,它是可以像普通的中断一样被悬起的(不像SVC 那样会上访)。OS 可以利用它“缓期执行”一个异常——直到其它重要的任务完成后才执行动作。悬起 PendSV 的方法是:手工往 NVIC 的 PendSV 悬起寄存器中写 1。悬起后,如果优先级不够高,则将缓期等待执行。
PendSV 的典型使用场合是在上下文切换时(在不同任务之间切换)。例如,一个系统中有两个就绪的任务,上下文切换被触发的场合可以是:
-
执行一个系统调用
-
系统滴答定时器(SYSTICK)中断,(轮转调度中需要)
让我们举个简单的例子来辅助理解。假设有这么一个系统,里面有两个就绪的任务,并且通过SysTick 异常启动上下文切换。如图 7.15 所示。
上图是两个任务轮转调度的示意图。但若在产生 SysTick 异常时正在响应一个中断,则SysTick 异常会抢占其 ISR。在这种情况下,OS 是不能执行上下文切换的,否则将使中断请求被延迟,而且在真实系统中延迟时间还往往不可预知——任何有一丁点实时要求的系统都决不能容忍这种事。因此,在 CM3 中也是严禁没商量——如果 OS 在某中断活跃时尝试切入线程模式,将触犯用法 fault 异常。
为解决此问题,早期的 OS 大多会检测当前是否有中断在活跃中,只有在无任何中断需要响应时,才执行上下文切换(切换期间无法响应中断)。然而,这种方法的弊端在于,它可以把任务切换动作拖延很久(因为如果抢占了 IRQ,则本次 SysTick 在执行后不得作上下文切换,只能等待下一次 SysTick 异常),尤其是当某中断源的频率和 SysTick 异常的频率比较接近时,会发生“共振”,使上下文切换迟迟不能进行。
PendSV 来完美解决这个问题了。PendSV 异常会自动延迟上下文切换的请求,直到其它的 ISR 都完成了处理后才放行。为实现这个机制,**需要把 PendSV 编程为最低优先级的异常。**如果 OS 检测到某 IRQ 正在活动并且被 SysTick 抢占,它将悬起一个 PendSV 异常,以便缓期执行上下文切换
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xSlupEjb-1650199898240)(https://www.houenup.com/wp-content/uploads/2022/04/image-20220401150619573.png)]
- 任务 A 呼叫 SVC 来请求任务切换(例如,等待某些工作完成)
- OS 接收到请求,做好上下文切换的准备,并且悬起一个 PendSV 异常。
- 当 CPU 退出 SVC 后,它立即进入 PendSV,从而执行上下文切换。
- 当 PendSV 执行完毕后,将返回到任务 B,同时进入线程模式。
- 发生了一个中断,并且中断服务程序开始执行
- 在 ISR 执行过程中,发生 SysTick 异常,并且抢占了该 ISR。(在freertos中systick中断优先级最低,不会出现这种情况)
- OS 执行必要的操作,然后悬起 PendSV 异常以作好上下文切换的准备。
- 当 SysTick 退出后,回到先前被抢占的 ISR 中,ISR 继续执行
- ISR 执行完毕并退出后,PendSV 服务例程开始执行,并且在里面执行上下文切换
- 当 PendSV 执行完毕后,回到任务 A,同时系统再次进入线程模式。
2.2.2.1.5 CM3异常处理
异常响应的处理序列
- 入栈操作:将PSR、PC、LR、R12、R0-R3压入适当的堆栈中**,如果当相应异常时当前的代码正在使用PSP,则压入PSP,即使用线程堆栈,否则压入MSP使用主堆栈**
- 取向量:从向量表中取出当前的异常/中断向量,然后我们就可以通过这个向量找到要运行的异常/中断处理程序的起始代码
- 更新寄存器
- 跳转到Handle中进行异常的处理
- 异常处理结束之后,进入到异常返回处理序列中:
- 出栈
- 更新NVIC中的寄存器
当 CM3 内核响应了一个发生的异常后,对应的**异常服务例程(ESR)**就会执行。为了决定 ESR 的入口地址,CM3 使用了“向量表查表机制”。这里使用一张向量表。向量表其实是一个 WORD(32 位整数)数组,每个下标对应一种异常,该下标元素的值则是该 ESR 的入口地址。向量表在地址空间中的位置是可以设置的,通过 NVIC 中的一个重定位寄存器来指出向量表的地址。在复位后,该寄存器的值为 0。因此,在地址 0 处必须包含一张向量表,用于初始时的异常分配
向量表结构:
举个例子,如果发生了异常 11(SVC),则 NVIC 会计算出偏移移量是 11x4=0x2C,然后从那里取出服务例程的入口地址并跳入。
因为地址 0 处应该存储引导代码,所以它通常是 Flash 或者是 ROM 器件,并且它们的值不得在运行时改变。然而,为了动态重分发中断,CM3 允许向量表重定位——从其它地址处开始定位各异常向量。这些地址对应的区域可以是代码区,但也可以是 RAM 区。在 RAM区就可以修改向量的入口地址了。为了实现这个功能,NVIC 中有一个寄存器,称为“向量表偏移量寄存器”(在地址 0xE000_ED08 处),通过修改它的值就能定位向量表。
2.2.2.2 启动调度器
2.2.2.2.1 vTaskStartScheduler() 函数 出现了IDLE到后面遇到再补上去解释
void vTaskStartScheduler( void )
{
BaseType_t xReturn;
/* Add the idle task at the lowest priority. */
StaticTask_t *pxIdleTaskTCBBuffer = NULL;
StackType_t *pxIdleTaskStackBuffer = NULL;
uint32_t ulIdleTaskStackSize;
/* The Idle task is created using user provided RAM - obtain the
address of the RAM then create the idle task. */
vApplicationGetIdleTaskMemory( &pxIdleTaskTCBBuffer, &pxIdleTaskStackBuffer, &ulIdleTaskStackSize );
xIdleTaskHandle = xTaskCreateStatic( prvIdleTask,
"IDLE",
ulIdleTaskStackSize,
( void * ) NULL,
( tskIDLE_PRIORITY | portPRIVILEGE_BIT ),
pxIdleTaskStackBuffer,
pxIdleTaskTCBBuffer ); /*lint !e961 MISRA exception, justified as it is not a redundant explicit cast to all supported compilers. */
/* Setting up the timer tick is hardware specific and thus in the
portable interface. */
if( xPortStartScheduler() != pdFALSE )
{
/* Should not reach here as if the scheduler is running the
function will not return. */
}
else
{
/* Should only reach here if a task calls xTaskEndScheduler(). */
}
}
2.2.2.2.2 xPortStartScheduler()启动调度器
这个函数在port.c中实现,不同的架构的芯片中该函数的内容存在区别。
#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的中断优先级为最低 */
/* Make PendSV and SysTick the lowest priority interrupts. */
portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;
/* 启动第一个任务,不再返回 */
/* Start the first task. */
prvPortStartFirstTask();
/* Should not get here! */
return 0;
}
SysTick和PendSV都会涉及系统调度,系统调度的优先级要低于系统的其他硬件中断优先级,即优先相应系统中的外部硬件中断,所以SysTick和PendSV的中断优先级配置位最低
2.2.2.2.3 prvPortStartFirstTask() 启动第一个任务
__asm void prvStartFirstTask( void )
{
/* 当前栈按照8字节对齐 */
PRESERVE8
/* Use the NVIC offset register to locate the stack. */
ldr r0, =0xE000ED08 //将0xE00ED08赋值给R0
ldr r0, [r0] //将0xE00ED08指向的内容(0X00000000)赋值给R0
ldr r0, [r0] //将0X00000000指向的内容(0x200008DB)赋值给R0
/* 设置主栈指针msp的值 */
/* Set the msp back to the start of the stack. */
msr msp, r0 //将R0的值(0x200008DB)赋给msp
/* 启用全局中断 */
/* Globally enable interrupts. */
cpsie i //开中断
cpsie f //开异常
dsb //数据同步隔离
isb //指令同步隔离
/* 调用SVC启动第一个任务 */
/* Call SVC to start the first task. */
svc 0
nop
nop
}
该函数用来开启第一个任务,主要进行两个曹操作:
- 更新MSP的值。
- 产生SVC系统调用,到SVC中断服务函数中真正切换到第一个任务。
2.2.2.2.4 vPortSVCHandler() SVC中断
__asm void vPortSVCHandler( void )
{
extern pxCurrentTCB;
PRESERVE8
ldr r3, =pxCurrentTCB /* Restore the context. */
ldr r1, [r3] /* Use pxCurrentTCB to get the pxCurrentTCB address. */
ldr r0, [r1] /* 加载pxCurrentTCB指向的任务控制块到r0,任务控制块的第一个成员就是栈顶指针,所以此时r0等于栈顶指针。 */
ldmia r0!, {r4-r11} /* 以R0为基地址,将栈中向上增长的8个字的内容加载到CPU寄存器r4~r11,r0也随之自增*/
msr psp, r0 /* 将新的栈顶指针r0更新到psp,任务执行时使用的栈指针是psp */
isb
mov r0, #0 /* R0清零 */
msr basepri, r0 /* 设置BASEPRI寄存器的值为0,即打开所有中断 */
orr r14, #0xd /* 从SVC中断服务无退出前,通过向R14寄存器最后4位按位或上0x0D,使得硬件在退出时使用进程栈指针psp完成出栈操作并返回后进入任务模式,返回Thumb模式,在SVC中断服务中,使用的是msp指针,处于ARM状态 */
bx r14 /* 异常返回,这时出栈使用的是psp指针,自动将栈中的剩余内容加载到CPU寄存器:xPSR、PC、,r14,r12,r3,r2,r1,r0,同时psp的值也会更新,指向了任务栈的栈顶 */
}
pxCurrentTCB是一个在task.c中定义的全局指针,用于指向当前正在运行或即将运行的任务的任务控制块(TCB).
在功能的表现上来说SVC中断就是将psp指针指向pxCurrentTCB的栈顶
2.2.2.2 任务切换
任务切换就是在就绪列表中寻找优先级最高的就绪任务,然后执行该任务
2.2.2.2.1 taskYIELD() 产生PendSV中断
#define taskYIELD() portYIELD()
#define portNVIC_INT_CTRL_REG ( * ( ( volatile uint32_t * ) 0xe000ed04 ) )
#define portNVIC_PENDSVSET_BIT ( 1UL << 28UL )
#define portSY_FULL_READ_WRITE ( 15 )
#define portYIELD() \
{
/* 触发PendSV,产生上下文切换 */ \
/* Set a PendSV to request a context switch. */ \
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \
\
/* Barriers are normally not required but do ensure the code is completely \
within the specified behaviour for the architecture. */ \
__dsb( portSY_FULL_READ_WRITE ); \
__isb( portSY_FULL_READ_WRITE ); \
}
整个函数实际上就是将PendSV的悬起位置1,当没有其他中断运行时相应PendSV中断,执行我们写好的PendSV中断服务函数
__DSB() __ISB()命令
等待指令和数据同步,例如*ocramAddr = 0xCCU;执行完成后,实际单片机可能还未完成执行,使用__DSB() __ISB()命令可以确保该行代码执行完成
__dsb: 程序通过中断信号进入中断处理函数时,首先应当清除相应的中断标志位,但有些CPU的时钟太快,快于中断使用的时钟,就会出现清除中断标志的动作还未完成,CPU就又一次重新进入同一个中断处理函数,导致死循环,__DSB() 指令的作用就是避免上述情况的发生。
2.2.2.2.2 xPortPendSVHandler() 进行任务切换
进入到这里之后就是用的msp堆栈指针
__asm void xPortPendSVHandler( void )
{
extern pxCurrentTCB;
extern vTaskSwitchContext;
PRESERVE8
mrs r0, psp /* 将psp的值装进r0 */
isb
ldr r3, =pxCurrentTCB /* R3中存放的是pxCurrentTCB的地址 */
ldr r2, [r3] /* 将R3中的内容(pxCurrentTCB,也就是TaskTCB的地址)存放进R2*/
stmdb r0!, {r4-r11} /* 以R0为基址,指针先递减,再操作,将CPU寄存器R4~R11的值存储到任务栈,同时更新R0的值 */
str r0, [r2] 将R0的值存储到R2指向的内容,具体为将R0的值存储到上一个任务的栈顶指针pxTopOfStack
/*-------------------------- 以上为上文的保存---------------------------*/
stmdb sp!, {r3, r14} /* 将r3和r14临时压入栈,压入的是主栈 因为接下来要调用vTaskSwitchContext(),调用函数时,返回地址自动保存到r14中,所以一旦调用发生,r14的值会被覆盖 PendSV中断服务函数执行完毕后,返回时需要根据r14的值来决定返回处理器模式还是任务模式、出栈时使用的是psp还是msp,因此需要入栈保护,r3保存的是当前正在运行的任务(准确来说是上文,因为接下来即将切换到新的任务)的TCB指针地址,函数调用后pxCurrentTCB的值会被更新,后面还需要通过r3来操作pxCurrentTCB,但是运行函数vTaskSwitchContext()时不确定会不会使用r3寄存器作为中间变量,所以为了保险,将r3也入栈保护起来*/
mov r0, %0
msr basepri, r0
bl vTaskSwitchContext /* 加载pxCurrentTCB的地址到r3 */
mov r0, #0
msr basepri, r0 /* 关中断,进入临界段,因为接下来要更新全局指针pxCurrentTCB */
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} /* Pop the registers. */
msr psp, r0 /* 更新psp的值,等异常退出时会以psp作为基地址,将任务栈中剩下的内容自动加载到CPU寄存器 */
isb
bx r14 /* 异常发生时,r14中保存异常返回标志,包括返回后进入任务模式还是处理器模式、使用psp栈指针还是msp栈指针,出栈完毕后psp指向任务栈的栈顶。当调用bx r14指令后,系统以psp作为sp指针出栈,把接下来要运行的新任务的任务栈中剩下的内容加载到CPU寄存器:r0、r1、r2、r3、r12、r14(LR)、r15(PC)和xPSR,从而切换到新的任务 */
}
stmdb:
PUSH 指令等效于与使用 R13 作为地址指针的 STMDB 指令,而 POP 指令则等效于使用R13 作为地址指针的 LDMIA 指令——STMDB/LDMIA 还可以使用其它寄存器作为地址指针
BL :
转移并连接。用于呼叫一个子程序,返回地址被存储在 LR 中
2.2.2.2.3 vTaskSwitchContext() 更新pxCurrentTCB
该函数的任务就是选择优先级最高的任务,然后更新pxCurrentTCB,由于还没有设计任务的优先级,这里先不写
2.2.2.3 任务调度总结与补充
任务调度分为两个部分:
- 第一次启动调度器时的调度初始化
- 在systick中断里不断调用taskYIELD进行的每一次的具体的任务调度
调度器初始化部分的流程图如下:
初始化就干了这么几件事:
- 设置PendSV、Systick优先级
- 设置堆栈指针,告诉单片机MSP要给我指到哪里,PSP通过一系列的移动最终移到第一个任务的任务栈的栈顶
- PSP的移动是通过将原本初始化好的任务栈的栈顶存到R0、R1、R3中,然后让PSP指向R0中的地址位置,然后以这个地址为基准最终向上将所有的内容压入CPU对应寄存器。
具体的每一次调度的流程如下:
调度的过程如下:
-
启动调度器:通过产生svc中断来将我们的程序跳转到第一个任务中,开始我们整个任务的调度。
-
在Systick中断中调用taskYIELD来触发PendSV进行具体地切换
-
在PendSV中断中的操作分为三步:
- 保存上文
- 跳转到任务切换函数将pxCurrentTCB更换掉
- 将新的任务栈参数:xpsr r15~r0 r4~r11装进cpu寄存器
- 利用中断返回跳转到新的任务中
这个过程总体来说没什么,但是我们最好是要知道任务切换是如何进行堆栈操作的。这个过程还是蛮奇妙的。
上面的两张图可能不太清晰,用pad写的有的地方字体太小看不清,可以在这里下载下来看:
FreeRTOS任务切换流程图
我花费了大量的时间来理解到底是怎么做到的将任务状态保存好,然后切换,再切回来的时候还是接着上次的状态的。其中因为对arm汇编的不熟练导致我多次误解程序的意思,从而产生无数的问号。后来慢慢捋了三四遍终于整清楚了。
首先说一下pxTopOfStack与PSP
pxTopOfStack是栈顶指针,它指向我们任务栈栈顶。但是它处于真正的栈顶的时候只有两种情况:
- 任务栈刚刚被初始化好的时候
- 在进行任务切换的时候进行上文的保存,保存完毕的时候
PSP是最主要的栈指针,利对它的利用这么种:
- 将PSP指向pxTopOfStack,然后从PSP开始向上将任务栈参数存入cpu
- 在进入PendSV的时候硬件自动将上文的xpsr~R0的值沿着进入中断的时候psp所在的位置向下存入上文的栈中进行保护(psp也向下更新),然后再以PSP的位置向下手动存入r4-r11进行保护,并且将pxTopOfStack的位置进行更新
然后来说一下高频运用的r0、r1、r2、r3
r0、r1、r2、r3主要被用来找pxTopOfStack的位置。看这个:
ldr r3, =pxCurrentTCB
ldr r1, [r3]
ldr r0, [r1]
pxCurrentTCB=&TaskTCB 而在这里r3存放的是pxCurrentTCB的地址,即&pxCurrentTCB
r1存放的是r3中的内容,即pxCurrentTCB ,也就是&TaskTCB
r0存放的是r1中的内容,即&TaskTCB这个地址指向的内容,因为pxTopOfStack是TaskTCB的第一个元素,所以&TaskTCB=&pxTopOfStack。这样 我们就找到了pxTopOfStack,当然,pxTopOfStack是一个地址。
r0、r1、r2、r3也可以用来修改新的pxTopOfStack,例如:
ldr r3, =pxCurrentTCB /* R3中存放的是pxCurrentTCB的地址 */
ldr r2, [r3] /* 将R3中的内容(pxCurrentTCB,也就是TaskTCB的地址)存放进R2*/
stmdb r0!, {r4-r11} /* 以R0为基址,指针先递减,再操作,将CPU寄存器R4~R11的值存储到任务栈,同时更新R0的值 */
str r0, [r2] 将R0的值存储到R2指向的内容,具体为将R0的值存储到上一个任务的栈顶指针
这里r2的内容是pxCurrentTCB ,也就是&TaskTCB,我们将r4-r11入到任务栈中,这个时候我们的保存上文的工作已经完成,此时的r0所在的位置就是我们下一次用到这个栈的时候的栈顶,也就是r0的位置就是pxTopOfStack所指的位置。
因此利用str 这个命令,将R0的值存储到R2指向的内容,即将r0所存的地址赋值给&TaskTCB,也就是&TaskTCB=&pxTopOfStack=r0中存放的地址。这样 pxtopOfStack就指向了真正的栈顶。
这个过程出现在PendSV中对于上文的保存过程,也就是说,我们的pxTopOfStack真正指向栈顶的时候是我们在将要转换到下文的时候。
最后我们来模拟一个场景,希望帮助理解:
我们现在正在执行任务A,任务运行了一半,systick中断来了,并且紧接着我们进到了PendSV中进行任务的切换了,这个时候我们要赶紧的保存当前的状态,cpu会立即将寄存器r0、r1、r2、r3、r11、r14、r15、XPSR沿着当前所在的任务栈的位置(也是psp现在的位置)往下存,存的时候PSP也往下走,但是保存的只有8个呀,还不足以描述这个任务退出时的状态,但是现在已经进到了PendSV中断中,因此我们还要在PendSV中断中手动将r4~r11接着往下保存,但是这个时候我们没有用psp指针,而是用r0找到psp的位置,然后在r0的基础上往下顺延将寄存器的数据入栈。然后将新的栈顶指针pxTopOfStack指向r0的地方。
假设我们任务转换了一圈,下一次就又要转到任务A了,我们所期望的结果就是再顺着上次任务A被打断的地方继续进行,于是我们在切换任务的时候就需要找到它的pxTopOfStack然后向上将它之前的状态存到cpu里,我们的cpu就知道上次任务A在哪被打断了,同时我们在存栈中的数据到cpu的同时会将PSP的指针移动,正好可以移动到上次被打断的地方,然后任务A 的栈就可以接着上次被打断的地方无缝衔接使用了。