FreeRTOS简单内核实现3 任务管理
0、思考与回答
0.1、思考一
对于 Cortex-M4 内核的 MCU 在发生异常/中断时,哪些寄存器会自动入栈,哪些需要手动入栈?
会自动入栈的寄存器如下
- R0 - R3:通用寄存器
- R12:通用寄存器
- LR (Link Register):链接寄存器,保存返回地址
- PC (Program Counter):程序计数器,保存当前执行指令的地址
- xPSR (Program Status Register):程序状态寄存器
需要手动入栈的寄存器如下
- R4 - R11:其他通用寄存器
0.2、思考二
这些入栈的寄存器是怎么找到要入栈的地址的呢?
依靠堆栈指针来确定入栈的地址,Cortex-M4 的堆栈指针寄存器 SP 在同一物理位置上有 MSP 和 PSP 两个堆栈指针,默认情况下会使用主堆栈指针 MSP
0.3、思考三
自动入栈的寄存器什么时候入栈,什么时候出栈恢复原来的处理器状态?
进入异常处理时会发生自动入栈,当异常处理完成并执行异常返回指令(bx r14)时,处理器会自动从堆栈中弹出这些寄存器的值
1、任务控制块
通常使用名为任务控制块(TCB)的结构体来对每个任务进行管理,该结构体中包含了任务管理所需要的一些重要成员,其中栈顶指针 pxTopOfStack
作为结构体的第一个成员,其地址也即任务控制块的地址,具体如下所示
/* task.h */
// 任务控制块
typedef struct tskTaskControlBlock
{
volatile StackType_t *pxTopOfStack; // 栈顶
ListItem_t xStateListItem; // 任务节点
StackType_t *pxStack; // 任务栈起始地址
char pcTaskName[configMAX_TASK_NAME_LEN]; // 任务名称
}tskTCB;
typedef tskTCB TCB_t;
configMAX_TASK_NAME_LEN
是一个宏,用于设置任务名称长度,具体定义如下
/* FreeRTOSConfig.h */
// 任务名称字符串长度
#define configMAX_TASK_NAME_LEN 24
2、创建任务
2.1、xTaskCreateStatic( )
静态创建任务时需要指定任务栈(地址和大小)、任务函数、任务控制块等参数,该函数就是负责将这些分散的变量联系在一起,方便后续对任务进行管理,但是其并不是真正的创建任务的函数,具体如下所示
/* task.c */
// 静态创建任务函数
#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;
// 将任务控制块的 pxStack 指针指向任务栈起始地址
pxNewTCB->pxStack = (StackType_t*)puxTaskBuffer;
// 真正的创建任务函数
prvInitialiseNewTask(pxTaskCode,
pcName,
ulStackDepth,
pvParameters,
&xReturn,
pxNewTCB);
}
else
{
xReturn = NULL;
}
// 任务创建成功后应该返回任务句柄,否则返回 NULL
return xReturn;
}
#endif
/* task.h */
// 函数声明
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);
configSUPPORT_STATIC_ALLOCATION
是一个宏,用于配置和裁剪 RTOS 静态创建任务的功能,具体定义如下所示
/* FreeRTOSConfig.h */
// 是否支持静态方式创建任务
#define configSUPPORT_STATIC_ALLOCATION 1
TaskHandle_t
是一个 void *
类型的变量,用于表示任务句柄,TaskFunction_t
是一个函数指针,用于表示任务函数,具体定义如下所示
/* task.h */
// 任务句柄指针
typedef void* TaskHandle_t;
// 任务函数指针
typedef void (*TaskFunction_t)(void *);
2.2、prvInitialiseNewTask( )
函数 xTaskCreateStatic()
最终调用了真正的创建任务函数 prvInitialiseNewTask()
,该函数主要是对任务栈内存、任务控制块成员等进行初始化,具体如下所示
/* task.c */
// 使用的外部函数声明
extern StackType_t* pxPortInitialiseStack(StackType_t* pxTopOfStack,
TaskFunction_t pxCode,
void* pvParameters);
// 真正的创建任务函数
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';
// 初始化链表项
vListInitialiseItem(&(pxNewTCB->xStateListItem));
// 设置该链表项的拥有者为 pxNewTCB
listSET_LIST_ITEM_OWNER(&(pxNewTCB->xStateListItem), pxNewTCB);
// 初始化任务栈
pxNewTCB->pxTopOfStack =
pxPortInitialiseStack(pxTopOfStack, pxTaskCode, pvParameters);
if((void*)pxCreatedTask != NULL)
{
*pxCreatedTask = (TaskHandle_t)pxNewTCB;
}
}
2.3、pxPortInitialiseStack( )
初始化任务栈函数,内存具体被初始化为什么样可以阅读 "2.4 任务内存详解" 小节
/* port.c */
// 错误出口
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;
// r12、r3、r2 和 r1 默认初始化为 0
pxTopOfStack -= 5;
*pxTopOfStack = (StackType_t)pvParameters;
// 异常发生时,手动加载到 CPU 的内容
pxTopOfStack -= 8;
// 返回栈顶指针,此时 pxTopOfStack 指向空闲栈
return pxTopOfStack;
}
/* portMacro.h */
#define portINITIAL_XPSR (0x01000000)
#define portSTART_ADDRESS_MASK ((StackType_t) 0xFFFFFFFEUL)
2.4、任务内存详解
使用静态方式创建任务之前需要提前定义好任务句柄、任务栈空间、任务控制块和任务函数,具体如下程序所示
// 任务句柄
TaskHandle_t Task_Handle;
// 任务栈大小
#define TASK_STACK_SIZE 128
// 任务栈空间
StackType_t TaskStack[TASK_STACK_SIZE];
// 任务控制块
TCB_t TaskTCB;
// 任务函数
void TaskFunction(void *parg)
{
for(;;)
{
}
}
定义好这些变量之后都会在 RAM 中占用一定的空间,其中任务控制块 TaskTCB
的地址和结构体第一个成员 pxTopOfStack
是同一个地址,假设这些变量在空间中的占用情况如下图所示
将这些定义好的变量作为参数传递给静态创建任务函数 xTaskCreateStatic()
,具体如下程序所示
// 静态方式创建任务
Task_Handle = xTaskCreateStatic(TaskFunction,
"Task",
128,
TaskParameters,
TaskStack,
TaskTCB);
该函数执行完毕之后,最终这些变量在空间中的占用情况如下图所示
为什么要按照顺序这么存放?
因为在 ARM Cortex-M 处理器中,当异常发生时硬件会自动按照上图的顺序将寄存器推入当前的栈中,同时退出异常时也会按照其相反的顺序从当前栈中取值加载到 MCU 寄存器中
为什么任务栈顶 PSR 的值为 0x01000000 ?
PSR
是程序状态寄存器,对于异常来说具体的寄存器为 EPSR
,其第 24 位为 Thumb 状态位(T位),在 ARM Cortex-M 架构中,所有代码都必须在 Thumb 模式下执行,寄存器定义可以在 Cortex-M4 Devices Generic User Guide 手册中找到,具体如下图所示
可以发现当执行 BX
指令退出异常时(后面启动任务 / 任务调度会频繁使用到异常退出指令),会将 T 位清零,而一旦清零并尝试执行指令就会导致 MCU 故障或锁定,因此必须在异常退出自动恢复 MCU 寄存器时将 T 位重新置 1
为什么任务栈存放任务函数入口地址的内容为 pxCode & portSTART_ADDRESS_MASK?
简单来说这步操作其实是为了内存对齐,portSTART_ADDRESS_MASK
是一个宏,pxCode & portSTART_ADDRESS_MASK
只是为了确保 pxCode 任务函数入口地址最低位置为 0
3、就绪链表
3.1、定义
为什么要定义链表?
使用链表可以将各个独立的任务链接起来,对于多任务时会方便任务管理
/* task.c */
// 就绪链表
List_t pxReadyTasksLists;
3.2、prvInitialiseTaskLists( )
链表创建后不能直接使用,需要调用 vListInitialise()
函数对链表进行初始化,由于后续会创建多个链表,因此将链表初始化操作包装为一个函数,具体如下所示
/* task.c */
// 就绪列表初始化函数
void prvInitialiseTaskLists(void)
{
vListInitialise(&pxReadyTasksLists);
}
/* task.h */
// 函数声明
void prvInitialiseTaskLists(void);
4、任务调度器
任务调度流程如下所示
- 启动调度器
- vTaskStartScheduler( )
- xPortStartScheduler( )
- 启动第一个任务
- prvStartFirstTask( )
- vPortSVCHandler( )
- 产生任务调度
- taskYIELD( )
- xPortPendSVHandler( )
- vTaskSwitchContext( )
4.1、vTaskStartScheduler( )
任务创建好了怎么执行呢?
这就是启动调度器函数的工作,选择一个任务然后启动第一个任务的执行
怎么选择任务?
通过一个名为 pxCurrentTCB
的 TCB_t *
类型的指针选择要执行的任务,该指针始终指向需要运行的任务的任务控制块
选择哪一个?
在创建多个任务时,可以通过一些方法(比如根据任务优先级)来选择先执行的任务,但是这里我们的 RTOS 内核还不支持优先级,因此可以先手动指定要执行的第一个任务
/* task.c */
// 当前 TCB 指针
TCB_t volatile *pxCurrentTCB = NULL;
// 使用的外部函数声明
extern BaseType_t xPortStartScheduler(void);
// 在 main.c 中定义的两个任务声明
extern TCB_t Task1TCB;
extern TCB_t Task2TCB;
// 启动任务调度器
void vTaskStartScheduler(void)
{
// 手动指定第一个运行的任务
pxCurrentTCB = &Task1TCB;
// 启动调度器
if(xPortStartScheduler() != pdFALSE)
{
// 调度器启动成功则不会到这里
}
}
/* task.h */
// 函数声明
void vTaskStartScheduler(void);
4.2、xPortStartScheduler( )
vTaskStartScheduler()
函数选择了要执行的任务,启动第一个任务执行的重任交给了 4.2 ~ 4.4 小节的三个函数,xPortStartScheduler()
函数主要设置 PendSV 和 SysTick 的中断优先级为最低,然后调用了 prvStartFirstTask()
函数
/* 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 中,PendSV 和 SysTick 中断服务函数均与任务调度有关,而任务调度优先级需要小于外部硬件中断的优先级,所以要将其设置为最低优先级
怎么设置 PendSV 和 SysTick 中断优先级为最低?
这里是直接操作寄存器的方法来设置 Cortex-M4 的 PendSV 和 SysTick 中断优先级的,可以在 Cortex-M4 Devices Generic User Guide 手册中找到有关 SHPR3 寄存器的地址与其每一位的含义,具体如下图所示
根据上图应该很好理解程序是怎么将 PendSV 和 SysTick 优先级设置为 15 的(NVIC 为 4 位抢占优先级,最低优先级就是 15)
/* portMacro.h */
#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)
/* FreeRTOSConfig.h */
// 设置内核中断优先级(最低优先级)
#define configKERNEL_INTERRUPT_PRIORITY 15
4.3、prvStartFirstTask( )
prvStartFirstTask
是使用汇编语言编写的函数,我们要实现的 RTOS 内核中一共有三个函数是由汇编语言编写的,除了这个外还有 4.4 和 4.5 两个小节的函数,该函数实际上仍然没有真正的启动第一个任务,实质上是触发了 SVC 中断
Keil 版本
/* port.c */
// 启动第一个任务,实际上是触发 SVC 中断
__asm void prvStartFirstTask(void)
{
// 8 字节对齐
PRESERVE8
// 在 cortex-M 中,0xE000ED08 是 SCB_VTOR 这个寄存器的地址,
// 里面放的是向量表的起始地址 ,其第一项为 __initial_sp,也即 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
}
// 函数声明
void prvStartFirstTask(void);
CLion 版本
void prvStartFirstTask(void)
{
__asm volatile
(
".align 8 \n"
"ldr r0,=0xE000ED08 \n"
"ldr r0,[r0] \n"
"ldr r0,[r0] \n"
"msr msp,r0 \n"
"cpsie i \n"
"cpsie f \n"
"dsb \n"
"isb \n"
"svc 0 \n"
"nop \n"
"nop \n"
);
}
4.4、vPortSVCHandler( )
SVC 中断服务函数,真正执行第一个任务的函数
Keil 版本
/* port.c */
// SVC 中断服务函数
__asm void vPortSVCHandler(void)
{
extern pxCurrentTCB;
// 8 字节对齐
PRESERVE8
// 将 pxCurrentTCB 指针的地址加载到寄存器 r3
ldr r3,=pxCurrentTCB
// 将 pxCurrentTCB 指针指向的地址(存储 TCB 的地址)加载到寄存器 r1
ldr r1,[r3]
// 将 pxCurrentTCB 指针指向的地址里的值(当前运行的 TCB 结构体)加载到寄存器 r0
// 也即寄存器 r0 中存储了当前运行的任务的栈顶指针地址
ldr r0,[r1]
// 以寄存器 r0 为基地址,将任务栈中存储的值加载到寄存器 r4 ~ r11 中
// 同时寄存器 r0 的值会自动更新
ldmia r0!,{r4-r11}
// 将更新后的寄存器 r0 的值加载给 psp
msr psp,r0
isb
// 开中断
mov r0,#0
msr basepri,r0
// 指示处理器在异常返回时使用 psp 作为堆栈指针
orr r14,#0xd
// 异常返回,自动加载剩下的 xPSR、PC、R14、R12 和 R3 ~ R0 寄存器
bx r14
}
CLion 版本
void vPortSVCHandler(void)
{
extern uint32_t pxCurrentTCB;
__asm volatile
(
".align 8 \n"
"ldr r3, =pxCurrentTCB \n"
"ldr r1, [r3] \n"
"ldr r0, [r1] \n"
"ldmia r0!, {r4-r11} \n"
"msr psp, r0 \n"
"isb \n"
"mov r0, #0 \n"
"msr basepri, r0 \n"
"orr lr, lr, #0x0d \n" // 笔者未验证,可能有误,请注意分辨
"bx lr \n"
:
:
: "r0", "r1", "r3", "memory"
);
}
熟悉 STM32 的读者应该了解,NVIC 中所有中断服务函数都有一个固定的函数名,该函数名可以在 startup_stm32f407xx.s
文件中找到,这里 vPortSVCHandler
其实是 SVC_Handler
的别名,具体宏定义如下所示
/* portMacro.h */
#define vPortSVCHandler SVC_Handler
注意:由于我们重新实现了 SVC 中断服务函数,因此在 stm32f4xx_it.c
中自动生成的 SVC_Handler
函数需要注释或者直接删除
4.5、xPortPendSVHandler( )
PendSV 中断服务函数,实现任务切换的函数
Keil 版本
/* port.c */
// PendSV 中断服务函数,真正实现任务切换的函数
__asm void xPortPendSVHandler(void)
{
// 只要触发 PendSV 中断,寄存器 xPSR、PC、R14、R12 和 R3 ~ R0,
// 这 8 个寄存器的值会自动从 psp 指针指向的地址开始加载到任务栈中
// 并且 psp 指针也会自动更新
extern pxCurrentTCB;
extern vTaskSwitchContext;
PRESERVE8
// 将 psp 的值存储到寄存器 r0 中
mrs r0,psp
isb
// 将 pxCurrentTCB 的地址存储到寄存器 r3 中
ldr r3,=pxCurrentTCB
// 将寄存器 r3 存储的地址里的内容存储到寄存器 r2 中
// 此时寄存器 r2 中存储了当前执行任务的 TCB 地址
ldr r2,[r3]
// 以寄存器 r0 为基地址(r0 中现在存储了 psp 指针的值)
// 将寄存器 r11 ~ r4 中存储的值存储到任务栈
// 并且 r0 中存储的地址指针也会自动更新
stmdb r0!,{r4-r11}
// 将寄存器 r0 中的内容存储到上一个执行任务的 TCB 地址
// 也即上一个任务的栈顶指针 pxTopOfStack 指向寄存器 r0 现在存储的地址
str r0,[r2]
// 上文保存完成
// 开始加载下文
// 将寄存器 r3 和 r14 压入栈
stmdb sp!,{r3,r14}
// 屏蔽 优先级值 高于或者等于 11 的中断
mov r0,#configMAX_SYSCALL_INTERRUPT_PRIORITY
msr basepri,r0
dsb
isb
// 跳转到 vTaskSwitchContext 函数,得到下一个要执行任务的指针 pxCurrentTCB
bl vTaskSwitchContext
// 开中断
mov r0,#0
msr basepri,r0
// 恢复寄存器 r3 和 r14
ldmia sp!,{r3,r14}
// 将 pxCurrentTCB 加载到寄存器 r1
ldr r1,[r3]
// 将下一个要执行任务的栈顶指针加载到寄存器 r0
ldr r0,[r1]
// 以寄存器 r0 为基地址(r0 中现在存储了任务的栈顶指针)
// 将任务栈中存储的值加载到寄存器 r4 ~ r11 中
// 同时 r0 中的值会自动更新
ldmia r0!,{r4-r11}
// 将更新后的 r0 的值加载到 psp
msr psp,r0
isb
// 异常退出,自动加载剩下的 xPSR、PC、R14、R12 和 R3 ~ R0 寄存器
bx r14
nop
}
CLion 版本
void xPortPendSVHandler(void)
{
extern uint32_t pxCurrentTCB;
extern void vTaskSwitchContext(void);
uint32_t temp;
__asm volatile
(
".align 8 \n"
"mrs r0, psp \n"
"isb \n"
"ldr r3, =pxCurrentTCB \n"
"ldr r2, [r3] \n"
"stmdb r0!, {r4-r11} \n"
"str r0, [r2] \n"
"stmdb sp!, {r3, lr} \n"
"mov r0, %[max_syscall_prio] \n"
"msr basepri, r0 \n"
"dsb \n"
"isb \n"
"bl vTaskSwitchContext \n"
"mov r0, #0 \n"
"msr basepri, r0 \n"
"ldmia sp!, {r3, lr} \n"
"ldr r1, [r3] \n"
"ldr r0, [r1] \n"
"ldmia r0!, {r4-r11} \n"
"msr psp, r0 \n"
"isb \n"
"bx lr \n"
"nop \n"
:
: [max_syscall_prio] "i" (configMAX_SYSCALL_INTERRUPT_PRIORITY)
: "r0", "r1", "r2", "r3", "memory"
);
}
xPortPendSVHandler
是 PendSV_Handler
的别名,用于表示 PendSV 中断服务函数入口地址
/* portMacro.h */
#define xPortPendSVHandler PendSV_Handler
关于如何屏蔽中断的请读者阅读后续 FreeRTOS简单内核实现4 临界段 文章中 ”思考三“ ,FreeRTOS 的中断管理策略是将所有的抢占优先级分为两组,一组可以通过 RTOS 的临界区进行屏蔽,另外一组不受 RTOS 的影响,这两组的边界则由 configMAX_SYSCALL_INTERRUPT_PRIORITY
宏确定,如下所示
/* FreeRTOSConfig.h */
// 设置 RTOS 可管理的最大中断优先级
#define configMAX_SYSCALL_INTERRUPT_PRIORITY 11<<4
注意:由于我们重新实现了 PendSV 中断服务函数,因此在 stm32f4xx_it.c
中自动生成的 PendSV_Handler
函数需要注释或者直接删除
4.6、vTaskSwitchContext( )
任务调度函数中通常应该使用某一调度策略来选择下一个要执行的任务(比如始终保证最高优先级的任务得到执行),但这里我们只简单的进行两个任务的交替,后面会对该函数完善,具体如下所示
/* task.c */
// 任务调度函数
void vTaskSwitchContext(void)
{
// 两个任务轮流切换
if(pxCurrentTCB == &Task1TCB)
{
pxCurrentTCB = &Task2TCB;
}
else
{
pxCurrentTCB = &Task1TCB;
}
}
/* task.h */
// 函数声明
void vTaskSwitchContext(void);
4.7、taskYIELD( )
taskYIELD()
是主动产生任务调度的宏函数,其实质上是触发了 PendSV 中断,然后在 PendSV 中断服务函数中产生了任务调度,具体如下所示
/* task.h */
// 主动产生任务调度
#define taskYIELD() portYIELD()
/* portMacro.h */
#define portNVIC_INT_CTRL_REG (*((volatile uint32_t*)0xE000ED04))
#define portNVIC_PENDSVSET_BIT (1UL << 28UL)
// 触发 PendSV,产生上下文切换
#define portYIELD() \
{ \
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \
__DSB(); \
__ISB(); \
};
同样这里是直接操作 Cortex-M4 内核 ICSR 寄存器的 PENDSVSET 位置 1 来产生 PendSV 中断的,关于该寄存器的相关内容仍然可以在 Cortex-M4 Devices Generic User Guide 手册中找到,具体的原理如下图所示
5、两个重要问题
5.1、启动第一个任务流程
通过上面的学习读者应初步了解到启动第一个任务的函数调用流程如下
vTaskStartScheduler()
- ->
xPortStartScheduler()
- ->
prvStartFirstTask()
- ->
vPortSVCHandler()
已知 prvStartFirstTask()
函数实质是触发了 SVC 中断,然后在 vPortSVCHandler()
这个 SVC 中断服务函数中启动了第一个任务,于是有如下问题
问题 1 :为什么在触发 SVC 中断前进行了设置主堆栈指针 msp 值的操作?
问题 2 :vPortSVCHandler()
函数究竟是怎么把第一个任务加载到 MCU 的寄存器中运行的?
5.1.1、问题 1 解答
这个问题的答案很简单,就是为了使 msp 指向一个有效的堆栈区域,因此将其初始化为默认状态,设置主栈指针的程序如下所示
ldr r0,=0xE000ED08
ldr r0,[r0]
ldr r0,[r0]
msr msp,r0
0xE000ED08
是 Cortex-M4 内核 VTOR
(中断向量偏移寄存器)的地址,这个寄存器是不是听起来很熟悉?
读者可以在 STM32 工程的启动文件中找到其中断向量偏移表,可以发现中断向量偏移表中第一项为 __initial_sp
,也即主堆栈指针 msp 的初始值,以 startup_stm32f407xx.s
为例,具体如下图所示
msp 主堆栈指针主要在系统发生中断/异常时使用,而在任务调度期间,FreeRTOS 使用 psp 来管理任务的堆栈,当发生中断/异常时系统中堆栈指针的操作如下:
- 当前 psp 指向的任务上下文会被保存
- 处理器切换到 msp,并在 msp 所指向的堆栈空间中保存中断上下文
- 中断处理程序执行,并使用 msp 进行堆栈操作
- 中断处理完成后,处理器会恢复 msp 中保存的上下文,返回到中断前的任务继续执行
5.1.2、问题 2 解答
当一个任务创建好之后,应该满足 &TaskTCB == &TaskTCB.pxTopOfStack
,并且他们都指向了任务栈中用于存放寄存器 r4
内存的地址,具体可以阅读 “2.4、任务内存详解” 小节内容
同时由于我们手动指定了 pxCurrentTCB
指针指向任务控制块,因此经过下面几句程序后,寄存器 r0
中值实际上已经是任务栈中用于存放寄存器 r4
内存的地址
ldr r3,=pxCurrentTCB
ldr r1,[r3]
ldr r0,[r1]
然后经过下面的程序,此时保存在任务栈中的 r4 ~ r11
的值就会加载到 MCU 的寄存器中,同时 r0
中的值也会自动更新,最终指向任务栈中用于存放寄存器 r0
内存的地址
ldmia r0!,{r4-r11}
接着将 r0
的值加载给 psp
指针,这时候 psp
指针指向了任务栈中用于存放寄存器 r0
内存的地址
msr psp,r0
isb
下一步在打开中断后执行了很重要的一步程序
orr r14,#0xd
执行完这条语句后,SVC 中断服务函数返回时就不再使用 msp
作为堆栈指针了,而是切换到了 psp
堆栈指针,这样 MCU 会从 psp
堆栈指针处将可以自动加载的寄存器进行加载,同时 psp
指针也会更新,最终 psp
指针会指向任务栈的栈顶
值得提醒的是由于任务创建时已经提前在任务栈用于存放寄存器 PC
内存处存储了任务函数的入口地址,因此第一个任务函数便会开始运行
5.2、任务调度原理
已知 RTOS 中发生任务调度其实质为触发 PendSV 中断,而 xPortPendSVHandler()
是 PendSV 中断服务函数,因此任务调度时上文的保存和下文的加载都是在该函数中发生的,于是有如下问题
问题 1 :上文是如何保存的?
问题 2 :下文是如何加载到 MCU 寄存器中的?
5.2.1、问题 1 解答
首先需要知道的是启动第一个任务后 psp
堆栈指针在哪里?它现在正指向任务栈的栈顶
其次需要知道当发生任务调度产生 PendSV 中断时,MCU 会从 psp
堆栈指针处自动保存那些可以自动保存的寄存器,并且 psp
也会跟着更新
于是当进入 PendSV 中断服务函数时,psp
指针已经指向了任务栈中用于存放寄存器 r0
内存的地址,然后将 psp
指针的值加载到 r0
寄存器中,并执行以下程序从 MCU 保存 r4 ~ r11
寄存器的值到任务栈对应位置,同时 r0
中的值也会更新,执行完之后 r0
此时应该指向任务栈中用于存放寄存器 r4
内存的地址
mrs r0,psp
isb
stmdb r0!,{r4-r11}
如下所示的几条程序则是将当前任务的任务控制块指向了 r0
中的值描述的地址处,也即当前任务的任务控制块指向了任务栈中用于存放寄存器 r4
内存的地址,可以发现和刚创建完成任务时一样,这样当前任务的上文就保存好了
ldr r3,=pxCurrentTCB
ldr r2,[r3]
str r0,[r2]
5.2.2、问题 2 解答
在保存好上文后加载下文前,中间的程序是为了找到下一个需要执行的任务,当找到下一个要执行的任务之后,加载下文的过程其实和启动第一个任务时大致相同,不再详细分析
6、实验
6.1、测试
使用 xTaskCreateStatic()
函数创建两个任务
- Task1 负责每隔 100ms 翻转一次 GREEN_LED 灯引脚的电平
- Task2 负责每隔 500ms 翻转一次 ORANGE_LED 灯引脚的电平
任务创建好之后将其添加到就绪链表中,然后启动任务调度器即可,具体代码如下所示
/* main.c */
/* USER CODE BEGIN Includes */
#include "FreeRTOS.h"
/* USER CODE END Includes */
/* USER CODE BEGIN PV */
extern List_t pxReadyTasksLists;
TaskHandle_t Task1_Handle;
#define TASK1_STACK_SIZE 128
StackType_t Task1Stack[TASK1_STACK_SIZE];
TCB_t Task1TCB;
TaskHandle_t Task2_Handle;
#define TASK2_STACK_SIZE 128
StackType_t Task2Stack[TASK2_STACK_SIZE];
TCB_t Task2TCB;
// 任务 1 入口函数
void Task1_Entry(void *parg)
{
for(;;)
{
HAL_GPIO_TogglePin(GREEN_LED_GPIO_Port, GREEN_LED_Pin);
HAL_Delay(100);
taskYIELD();
}
}
// 任务 2 入口函数
void Task2_Entry(void *parg)
{
for(;;)
{
HAL_GPIO_TogglePin(ORANGE_LED_GPIO_Port, ORANGE_LED_Pin);
HAL_Delay(500);
taskYIELD();
}
}
/* USER CODE END PV */
/* USER CODE BEGIN 2 */
// 使用链表前手动初始化
prvInitialiseTaskLists();
// 创建任务 1 和 2
Task1_Handle = xTaskCreateStatic((TaskFunction_t)Task1_Entry,
(char *)"Task1",
(uint32_t)TASK1_STACK_SIZE,
(void *)NULL,
(StackType_t *)Task1Stack,
(TCB_t *)&Task1TCB);
Task2_Handle = xTaskCreateStatic((TaskFunction_t)Task2_Entry,
(char *)"Task2",
(uint32_t)TASK2_STACK_SIZE,
(void *) NULL,
(StackType_t *)Task2Stack,
(TCB_t *)&Task2TCB );
// 将两个任务插入到就绪链表中
vListInsertEnd(&(pxReadyTasksLists),&(((TCB_t *)(&Task1TCB))->xStateListItem));
vListInsertEnd(&(pxReadyTasksLists),&(((TCB_t *)(&Task2TCB))->xStateListItem));
// 启动任务调度器,永不返回
vTaskStartScheduler();
/* USER CODE END 2 */
启动任务调度器后的程序执行流程如下所示,第一次执行完步骤 6 后会不断重复步骤 3 ~ 6
vTaskStartScheduler()
->pxCurrentTCB = &Task1TCB;
->xPortStartScheduler()
- ->
prvStartFirstTask()
->vPortSVCHandler()
->Task1_Entry()
- ->
taskYIELD()
->xPortPendSVHandler()
->vTaskSwitchContext()
- ->
Task2_Entry()
- ->
taskYIELD()
->xPortPendSVHandler()
->vTaskSwitchContext()
- ->
Task1_Entry()
使用逻辑分析仪捕获 GREEN_LED 和 ORANGE_LED 两个引脚的电平变化,具体如下图所示
可以发现两个任务不是并行运行的,而是一个任务执行完,第二个任务才会得到执行,所以两个 LED 灯引脚电平都是每隔 600ms 翻转一次,不是我们期待的 Task1 引脚电平每隔 100 ms 翻转一次,Task2 引脚电平每隔 500ms 翻转一次,这样的效果其实可被如下简单代码取代
while(1)
{
HAL_GPIO_TogglePin(GREEN_LED_GPIO_Port, GREEN_LED_Pin);
HAL_Delay(100);
HAL_GPIO_TogglePin(ORANGE_LED_GPIO_Port, ORANGE_LED_Pin);
HAL_Delay(500);
}
6.2、待改进
当前 RTOS 简单内核已实现的功能有
- 静态方式创建任务
- 手动切换任务
当前 RTOS 简单内核存在的缺点有
- 不支持任务优先级
- 任务不能并行运行
- 无中断临界段保护