本节目标
本节属于基础中的基础,必须要学会创建任务,并重点掌握任务是如何切换的。因为在FreeRTOS任务的切换是由汇编代码来完成的,所以代码看起来比较难懂,我们耐心细看。
本小节我们会创建两个任务,并让这两个任务不断地切换,任务的主体都是让一个变量按照一定的频率翻转,通过 KEIL 的软件仿真功能,在逻辑分析仪中观察变量的波形变化。
在多任务系统中,两个任务不断切换的效果图应该两个变量的波形是完全一样的,就好像 CPU 在同时干两件事一样,这才是多任务的意义。
这节只是开始,我们先掌握好任务是如何切换,在后面章节中,我们会陆续的完善功能代码,加入系统调度,实现真正的多任务。
什么是任务
在裸机系统中,系统的主体就是 main 函数里面顺序执行的无限循环,这个无限循环里面 CPU 按照顺序完成各种事情。在多任务系统中,我们根据功能的不同,把整个系统分割成一个个独立的且无法返回的函数,这个函数我们称为任务。
void task_entry (void *parg)
{
/* 任务主体,无限循环且不能返回 */
for (;;)
{
/* 任务主体代码 */
}
}
创建任务
定义任务栈
在多任务系统中,每个任务都是独立的,互不干扰的,所以要为每个任务都分配独立的栈空间,这个栈空间通常是一个预先定义好的全局数组,也可以是动态分配的一段内存空间,但它们都存在于 RAM 中。
本节我们要实现两个变量按照一定的频率轮流的翻转,每个变量对应一个任务,那么就需要定义两个任务栈。在多任务系统中,有多少个任务就需要定义多少个任务栈。
#define TASK1_STACK_SIZE 128
StackType_t Task1Stack[TASK1_STACK_SIZE];
#define TASK2_STACK_SIZE 128
StackType_t Task2Stack[TASK2_STACK_SIZE];
定义任务函数
任务是一个独立的函数,函数主体无限循环且不能返回。本节我们在 main.c 中定义的两个任务。
/* 软件延时 */
void delay (uint32_t count)
{
for(; count!=0; count--);
}
/* 任务1 */
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 );
}
}
定义任务控制块
在多任务系统中,任务的执行是由系统调度的。系统为了顺利的调度任务,为每个任务都额外定义了一个任务控制块,这个任务控制块就相当于任务的身份证,里面存有任务的所有信息,比如任务的栈指针,任务名称,任务的形参等。有了这个任务控制块之后,以后系统对任务的全部操作都可以通过这个任务控制块来实现。定义一个任务控制块需要一个新的数据类型,该数据类型在 task.c 这 C 头文件中声明。(为了 tskTCB这个数据类型能在其他地方使用,这个实验我把这个任务控制块的声明放在了 FreeRTOS.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;
在 FreeRTOSConfig.h 中定义宏configMAX_TASK_NAME_LEN来控制任务名称长度,字符串形式,默认为 16。
#define configMAX_TASK_NAME_LEN ( 16 )
在本节实验中,我们在 main.c 文件中为两个任务定义的任务控制块
/* 定义任务控制块 */
TCB_t Task1TCB;
TCB_t Task2TCB;
实现任务创建函数
任务的栈,任务的函数实体,任务的控制块最终需要联系起来才能由系统进行统一调度。那么这个联系的工作就由任务创建函数xTaskCreateStatic() 来实现,该函数在 task.c(task.c 第一次使用需要自行在文件夹 freertos 中新建并添加到工程的 freertos/source 组)中定义,在 task.h 中声明,所有跟任务相关的函数都在这个文件定义。
xTaskCreateStatic() 函数
#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) /* 任务控制块指针 */
{
TaskHandle_t xReturn; //③任务句柄用于指向任务的TCB
TCB_t *pxNewTCB; //初始化xNewTCB为TCB结构体
//任务栈起始地址和任务控制块的参数传入NewTCB
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 */
①FreeRTOS 中,任务的创建有两种方法,一种是使用动态创建,一种是使用静态创建。动态创建时,任务控制块和栈的内存是创建任务时动态分配的,任务删除时,内存可以释放。静态创建时,任务控制块和栈的内存需要事先定义好,是静态的内存,任务删除时,内存不能释放。目前我们以静态创建为例,configSUP-PORT_STATIC_ALLOCATION 在 FreeRTOSConfig.h 中定义,我们配置为 1。
#define configSUPPORT_STATIC_ALLOCATION 1
②任务入口,即任务的函数名称。TaskFunction_t是在projdefs.h(projdefs.h第一次使用需要在 include 文件夹下面新建然后添加到工程 freertos/source 这个组文件)中重定义的一个数据类型,实际就是空指针。
#ifndef PROJDEFS_H
#define PROJDEFS_H
typedef void (*TaskFunction_t)( void * );
#endif
③定义一个任务句柄 xReturn,任务句柄用于指向任务的 TCB。任务句柄的数据类型为 TaskHandle_t,在 task.h 中定义,实际上就是一个空指针。
typedef void * TaskHandle_t;
④调用 prvInitialiseNewTask() 函数,创建新任务,该函数在 task.c 实现
prvInitialiseNewTask() 函数
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; //定义一个UBaseType_t数据辅助值x,辅助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;
}
}
/* 任务名字的长度不能超过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;
}
}
①将栈顶指针向下做 8 字节对齐。在 Cortex-M3(Cortex-M4 或 Cortex-M7)内核的单片机中,因为总线宽度是 32 位的,通常只要栈保持 4 字节对齐就行,可这样为啥要 8 字节?难道有哪些操作是 64 位的?确实有,那就是浮点运算,所以要 8 字节对齐(但是目前我们都还没有涉及浮点运算,只是为了后续兼容浮点运行的考虑)。如果栈顶指针是 8 字节对齐的,在进行向下 8 字节对齐的时候,指针不会移动,如果不是 8 字节对齐的,在做向下 8 字节对齐的时候,就会空出几个字节,不会使用,比如当 pxTopOfStack是 33,明显不能整除 8,进行向下 8 字节对齐就是 32,那么就会空出一个字节不使用。
②调用 pxPortInitialiseStack() 函数初始化任务栈,并更新栈顶指针,任务第一次运行的环境参数就存在任务栈中。该函数在 port.c(port.c 第一次使用需要在freertosportableRVDSARM_CM3(ARM_CM4 或 ARM_CM7)文件夹下面新建然后添加到工程freertos/source 这个组文件)中定义。
pxPortInitialiseStack() 函数
#define portINITIAL_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 )
{
/* ①异常发生时,自动加载到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;
}
①异常发生时,CPU 自动从栈中加载到 CPU 寄存器的内容。包括 8 个寄存器,分别为 R0、R1、R2、R3、R12、R14、R15 和 xPSR 的位 24,且顺序不能变。
② 任务的返回地址,通常任务是不会返回的,如果返回了就跳转到prvTaskExitError,该函数是一个无限循环。
③返回栈顶指针,此时 pxTopOfStack 指向具体入上图 。任务第一次运行时,就是从这个栈指针开始手动加载 8 个字的内容到 CPU 寄存器:R4、R5、R6、R7、R8、R9、R10 和 R11,当退出异常时,栈中剩下的 8 个字的内容会自动加载到 CPU 寄存器:R0、R1、R2、R3、R12、R14、R15 和 xPSR 的位 24。此时 PC 指针就指向了任务入口地址,
从而成功跳转到第一个任务。
实现就绪列表
定义就绪列表
任务创建好之后,我们需要把任务添加到就绪列表里面,表示任务已经就绪,系统随时可以调度。就绪列表在 task.c 中定义。
/* 任务就绪列表 */
List_t pxReadyTasksLists[ configMAX_PRIORITIES ];
就绪列表实际上就是一个List_t类型的数组,数组的大小由决定最大任务优先级的宏 configMAX_PRIORITIES 决定,configMAX_PRIORITIES 在 FreeRTOSCon-
fig.h 中默认定义为 5,最大支持 256 个优先级。
#define configMAX_PRIORITIES ( 5 )
数组的下标对应了任务的优先级,同一优先级的任务统一插入到就绪列表的同一条链表中。一个空的就绪列表具体如下图:
就绪列表初始化
就绪列表在使用前需要先初始化,就绪列表初始化的工作在task.c函数 prvInitialiseTaskLists() 里面实现。
prvInitialiseTaskLists()
void prvInitialiseTaskLists( void )
{
UBaseType_t uxPriority;
for( uxPriority = ( UBaseType_t ) 0U; uxPriority < ( UBaseType_t ) configMAX_PRIORITIES; uxPriority++)
{
vListInitialise( &( pxReadyTasksLists[ uxPriority ] ) );
}
}
将任务插入到就绪列表
任务控制块里面有一个 xStateListItem 成员,数据类型为 ListItem_t,我们将任务插入到就绪列表里面,就是通过将任务控制块的 xStateListItem 这个列表项插入到就绪列表中来实现的。如果把就绪列表比作是晾衣架,任务是衣服,那xStateListItem 就是晾衣架上面的钩子,每个任务都自带晾衣架钩子,就是为了把自己挂在各种不同的列表中。
在本节实验中,我们在任务创建好之后,紧跟着将任务插入到就绪列表,在main.c中实现。
TaskHandle_t Task1_Handle;
TaskHandle_t Task2_Handle;
int main(void)
{
/* 初始化与任务相关的列表,如就绪列表 */
prvInitialiseTaskLists();
/* 创建任务 */
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 ) );
Task2_Handle = xTaskCreateStatic( ( TaskFunction_t )Task2_Entry,
(char *)"Task2",
(uint32_t)TASK2_STACK_SIZE,
(void *)NULL,
(StackType_t *)Task2Stack,
(TCB_t *)&Task2TCB);
/* 将任务添加到就绪列表 */
vListInsertEnd( &( pxReadyTasksLists[2]), &( ((TCB_t *)(&Task2TCB))->xStateListItem ) );
for(;;)
{
/*啥事不干*/
}
}
就绪列表的下标对应的是任务的优先级,但是目前我们的任务还不支持优先级,有关支持多优先级的知识点我们后面会讲到,所以 Task1 和 Task2 任务在插入到就绪列表的时候,可以随便选择插入的位置。我们选择将 Task1 任务插入到就绪列表下标为 1 的列表中,Task2 任务插入到就绪列表下标为 2 的列表中。
实现调度器
调度器是操作系统的核心,其主要功能就是实现任务的切换,即从就绪列表里面找到优先级最高的任务,然后去执行该任务。从代码上来看,调度器无非也就是由几个全局变量和一些可以实现任务切换的函数组成,全部都在 task.c 文件中实现。
启动调度器
调度器的启动由 vTaskStartScheduler() 函数来完成,该函数在 task.c 中定义。
vTaskStartScheduler() 函数
/* 当前正在运行的任务的任务控制块指针,默认初始化为NULL */
TCB_t * volatile pxCurrentTCB = NULL;
extern TCB_t Task1TCB;
void vTaskStartScheduler( void )
{
/* ①手动指定第一个运行的任务 */
pxCurrentTCB = &Task1TCB;
/* ②启动调度器 */
if( xPortStartScheduler() != pdFALSE )
{
}
}
①pxCurrentTCB 是一个在 task.c 定义的全局指针,用于指向当前正在运行或者即将要运行的任务的任务控制块。目前我们还不支持优先级,则手动指定第一个要运行的任务。
②调用函数 xPortStartScheduler() 启动调度器,调度器启动成功,则不会返回。该函数在 port.c 中实现。在projdefs.h,定义宏pdFALSE,表示状态。
#define pdFALSE ( ( BaseType_t ) 0 )
#define pdTRUE ( ( BaseType_t ) 1 )
#define pdPASS ( pdTRUE )
#define pdFAIL ( pdFALSE )
xPortStartScheduler() 函数
/*
* 参考资料《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;
}
①配置 PendSV 和 SysTick 的中断优先级为最低。SysTick 和 PendSV 都会涉及系统调度,系统调度的优先级要低于系统的其他硬件中断优先级,即优先相应系统中的外部硬件中断,所以 SysTick 和 PendSV 的中断优先级配置为最低。
②调用函数 prvStartFirstTask() 启动第一个任务,启动成功后,则不再返回,该函数由汇编编写,在 port.c 实现。
宏configKERNEL_INTERRUPT_PRIORITY需要在FreeRTOSConfig.h中定义。
#define configKERNEL_INTERRUPT_PRIORITY 255 /* 高四位有效,即等于0xff,或者是15 */
prvStartFirstTask() 函数
prvStartFirstTask() 函数用于开始第一个任务,主要做了两个动作,一个是更新 MSP 的值,二是产生 SVC 系统调用,然后去到 SVC 的中断服务函数里面真正切换到第一个任务。
/*
* 参考资料《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
}
①当前栈需按照 8 字节对齐,如果都是 32 位的操作则 4 个字节对齐即可。在 Cortex-M 中浮点运算是 8 字节的。
②在 Cortex-M 中,0xE000ED08 是 SCB_VTOR 寄存器的地址,里面存放的是向量表的起始地址,即 MSP 的地址。向量表通常是从内部 FLASH 的起始地址开始存放,那么可知 memory:0x00000000 处存放的就是 MSP 的值。这个可以通过仿真时查看内存的值证实,如下图
③将0xE000ED08 (SCB_VTOR这个寄存器的地址)这个立即数加载到寄存器 R0。
④将0xE000ED08 ( SCB_VTOR这个寄存器的地址)指向的内容 (SCB_VTOR 寄存器的值)加载到寄存器 R0,此时 R0 等于 SCB_VTOR 寄存器的值,等于 0x00000000( MSP要更新值的地址(memory 的起始地址))。
⑤将 0x00000000(MSP要更新值的地址) 指向的内容(MSP更新值)加载到 R0,此时 R0 等于0x200008DB(MSP要更新值)。
⑥将 R0 的值(MSP要更新值)存储到 MSP,此时 MSP 等于0x200008DB,这是主栈的栈顶指针。起始这一步操作有点多余,因为当系统启动的时候,执行完 Reset_Handler 的时候,向量表已经初始化完毕,MSP 的值就已经更新为向量表的起始值,即指向主栈的栈顶指针。
⑦使用 CPS 指令把全局中断打开。为了快速地开关中断,Cortex-M 内核专门设置了一条 CPS 指令,有 4 种用法:
CPSID I ;PRIMASK=1 ; // 关中断
CPSIE I ;PRIMASK=0 ; // 开中断
CPSID F ;FAULTMASK=1 ;// 关异常
CPSIE F ;FAULTMASK=0 ;// 开异常
PRIMASK 和 FAULTMAST 是 Cortex-M 内核里面三个中断屏蔽寄存器中的两个,还有一个是BASEPRI,有关这三个寄存器的详细用法见下表。
名字 | 功能描述 |
---|---|
PRIMASK | 这是个只有单一比特的寄存器。在它被置1后,就关掉所有可屏蔽的异常,只剩下 NMI 和硬FAULT 可以响应。它的缺省值是 0,表示没有关中断。 |
FAULTMASK | 这是个只有 1 个位的寄存器。当它置 1 时,只有 NMI 才能响应,所有其他的异常,甚至是硬FAULT,也通通闭嘴。它的缺省值也是 0,表示没有关异常。 |
BASEPRI | 这个寄存器最多有 9 位(由表达优先级的位数决定)。它定义了被屏蔽优先级的阈值。当它被设成某个值后,所有优先级号大于等于此值的中断都被关(优先级号越大,优先级越低)。但若被设成 0,则不关闭任何中断,0 也是缺省值。 |
⑧产生系统调用,服务号 0 表示 SVC 中断,接下来将会执行 SVC 中断服务函数,SVC 中断服务函数在port.c中实现。
vPortSVCHandler() 函数
SVC 中断要想被成功响应,其函数名必须与向量表注册的名称一致,在启动文件的向量表中,SVC 的中断服务函数注册的名称是 SVC_Handler,所以 SVC 中断服务函数的名称我们应该写成SVC_Handler,但是在 FreeRTOS 中,官方版本写的是 vPortSVCHandler(),为了能够顺利的响应SVC 中断,我们有两个选择,改中断向量表中 SVC 的注册的函数名称或者改 FreeRTOS 中 SVC的中断服务名称。这里,我们采取第二种方法,即在FreeRTOSConfig.h 中添加添加宏定义的方法来修改,顺便把PendSV 和 SysTick 的中断服务函数名也改成与向量表的一致,还有configMAX_SYSCALL_INTERRUPT_PRIORITY的宏也一起定义了。
#define configMAX_SYSCALL_INTERRUPT_PRIORITY 191 /* 高四位有效,即等于0xb0,或者是11 */
#define xPortPendSVHandler PendSV_Handler
#define xPortSysTickHandler SysTick_Handler
#define vPortSVCHandler SVC_Handler
vPortSVCHandler() 函数开始真正启动第一个任务,不再返回。R0主要存放栈顶指针 ,R1存放当前任务控制块的值,R3存放当前任务控制块地址,R4~R11被R0作为基地址将栈中向上增长的 8 个字的内容加载,R14是返回Thumb 状态。
__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的值也将更新,即指向任务栈的栈顶 */
}
①声明外部变量 pxCurrentTCB,pxCurrentTCB 是一个在 task.c 中定义的全局指针,用于指向当前正在运行或者即将要运行的任务的任务控制块。
②加载 pxCurrentTCB 的地址到 r3。
③加载 pxCurrentTCB 到 r3。
④加载 pxCurrentTCB 指向的任务控制块到 r0,任务控制块的第一成员就是栈顶指针,所以此时 r0 等于栈顶指针。一个刚刚被创建还没有运行过的任务的栈空间分布具体如下图所示,即 r0 等于图中的 pxTopOfStack。
⑤以 r0 为基地址,将栈中向上增长的 8 个字的内容加载到 CPU 寄存器 r4~r11,同时 r0 也会跟着自增。
⑥将新的栈顶指针 r0 更新到 psp,任务执行的时候使用的栈指针是psp。
⑦将寄存器 r0 清 0。
⑧设置 basepri 寄存器的值为 0,即打开所有中断。basepri 是一个中断屏蔽寄存器,大于等于此寄存器值的中断都将被屏蔽。
⑨当从 SVC 中断服务退出前,通过向 r14 寄存器最后 4 位按位或上0x0D,使得硬件在退出时使用进程栈指针 PSP 完成出栈操作并返回后进入任务模式、返回Thumb 状态。在 SVC 中断服务里面,使用的是 MSP 栈指针,是处在 ARM 状态。
⑩异常返回,这个时候出栈使用的是 PSP 指针,自动将栈中的剩下内容加载到 CPU 寄存器:xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)同时 PSP 的值也将更新,即指向任务栈的栈顶,具体指向如下图。
任务切换
任务切换就是在就绪列表中寻找优先级最高的就绪任务,然后去执行该任务。但是目前我们还不支持优先级,仅实现两个任务轮流切换。
taskYIELD()
/* 在 task.h 中定义 */
#define taskYIELD() portYIELD()
/* 在 portmacro.h 中定义 */
/* 中断控制状态寄存器: 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 中断服务函数,在里面实现任务切换。
xPortPendSVHandler() 函数
PendSV 中断服务函数是真正实现任务切换的地方,切换任务时要把上一个任务的运行环境保存,R3、R14的值压入栈中保护(目的是再一次切换回来这个任务的时候,任务运行到的内容一致),再更新现在要运行的任务。
__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//(11)
dsb
isb
bl vTaskSwitchContext /* (12)调用函数vTaskSwitchContext,寻找新的任务运行,通过使变量pxCurrentTCB指向新的任务来实现任务切换 */
mov r0, #0 /* (13)退出临界段 */
msr basepri, r0
ldmia sp!, {r3, r14} /* (14)恢复r3和r14 */
ldr r1, [r3]//(15)
ldr r0, [r1] /* (16)当前激活的任务TCB第一项保存了任务堆栈的栈顶,现在栈顶值存入R0*/
ldmia r0!, {r4-r11} /* (17)出栈 */
msr psp, r0//(18)
isb
bx r14 /* (19)异常发生时,R14中保存异常返回标志,包括返回后进入线程模式还是处理器模式、
使用PSP堆栈指针还是MSP堆栈指针,当调用 bx r14指令后,硬件会知道要从异常返回,
然后出栈,这个时候堆栈指针PSP已经指向了新任务堆栈的正确位置,
当新任务的运行地址被出栈到PC寄存器后,新的任务也会被执行。*/
nop
}
①声明外部变量 pxCurrentTCB,pxCurrentTCB 是一个在 task.c 中定义的全局指针,用于指向当前正在运行或者即将要运行的任务的任务控制块。
②声明外部函数 vTaskSwitchContext,等下会用到。
③当前栈需按照 8 字节对齐,如果都是 32 位的操作则 4 个字节对齐即可。在 Cortex-M 中浮点运算是 8 字节的。
④将 PSP 的值存储到 r0。当进入 PendSVC Handler 时,上一个任务运行的环境即:xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)这些 CPU 寄存器的值会自动存储到任务的栈中,剩下的 r4~r11 需要手动保存,同时 PSP 会自动更新(在更新之前 PSP 指向任务栈的栈顶),此时 PSP 具体指向如图
⑤加载 pxCurrentTCB 的地址到 r3。
⑥加载 r3 指向的内容到 r2,即 r2 等于pxCurrentTCB。
⑦以 r0 作为基址(指针先递减,再操作,STMDB 的 DB 表示 DecreaseBefor),将 CPU 寄存器 r4~r11 的值存储到任务栈,同时更新 r0 的值,此时 r0 的指向如图。
⑧将 r0 的值存储到 r2 指向的内容,r2 等于pxCurrentTCB。具体为将r0 的值存储到上一个任务的栈顶指针 pxTopOfStack。到此,上下文切换中的上文保存就完成了。
⑨将 R3 和 R14 临时压入栈(在整个系统中,中断使用的是主栈,栈指针使用的是 MSP),因为接下来要调用函数 vTaskSwitchContext,调用函数时,返回地址自动保存到 R14 中,所以一旦调用发生,R14 的值会被覆盖(PendSV 中断服务函数执行完毕后,返回的时候需要根据 R14 的值来决定返回处理器模式还是任务模式,出栈时使用的是 PSP 还是 MSP),因此需要入栈保护。R3 保存的是当前正在运行的任务(准确来说是上文,因为接下来即将要切换到新的任务)的 TCB 指针 (pxCurrentTCB) 地址,函数调用后pxCurrentTCB 的值会被更新,后面我们还需要通过 R3 来操作 pxCurrentTCB,但是运行函数vTaskSwitchContext 时不确定会不会使用 R3 寄存器作为中间变量,所以为了保险起见,R3也入栈保护起来。
⑩将 configMAX_SYSCALL_INTERRUPT_PRIORITY 的值存储到 r0,该宏在 FreeRTOSConfig.h 中定义,用来配置中断屏蔽寄存器 BASEPRI 的值,高四位有效。目前配置为 191,因为是高四位有效,所以实际值等于 11,即优先级高于或者等于 11 的中断都将被屏蔽。在关中断方面,FreeRTOS 与其他的 RTOS 关中断不同,而是操作 BASEPRI寄存器来预留一部分中断,并不像 μC/OS 或者 RT-Thread 那样直接操作 PRIMASK 把所有中断都关闭掉(除了硬 FAULT)。
(11)关中断,进入临界段,因为接下来要更新全局指针 pxCurrentTCB的值。
(12)调用函数 vTaskSwitchContext。该函数在 task.c 中定义,作用只有一个,选择优先级最高的任务,然后更新 pxCurrentTCB。目前我们还不支持优先级,则手动切换,不是任务 1 就是任务 2,该函数的具体实现在task.c。
vTaskSwitchContext() 函数
void vTaskSwitchContext( void )
{
/* 两个任务轮流切换 */
if( pxCurrentTCB == &Task1TCB )
{
pxCurrentTCB = &Task2TCB;
}
else
{
pxCurrentTCB = &Task1TCB;
}
}
(13)退出临界段,开中断,直接往 BASEPRI 写 0。
(14)从主栈中恢复寄存器 r3 和 r14 的值,此时的 sp 使用的是 MSP。
(15)加载 r3 指向的内容到 r1。r3 存放的是 pxCurrentTCB 的地址,即让 r1 等于 pxCurrentTCB。pxCurrentTCB 在上面的 vTaskSwitchContext 函数中被更新,指向了下一个将要运行的任务的 TCB。
(16)加载 r1 指向的内容到 r0,即下一个要运行的任务的栈顶指针。
(17)以 r0 作为基地址(先取值,再递增指针,LDMIA 的 IA 表示 IncreaseAfter),将下一个要运行的任务的任务栈的内容加载到 CPU 寄存器 r4~r11。
(18)更新 psp 的值,等下异常退出时,会以 psp 作为基地址,将任务栈中剩下的内容自动加载到 CPU 寄存器。
(19)异常发生时,R14 中保存异常返回标志,包括返回后进入任务模式还是处理器模式、使用 PSP 栈指针还是 MSP 栈指针。此时的 r14 等于 0xfffffffd,最表示异常返回后进入任务模式,SP 以 PSP 作为栈指针出栈,出栈完毕后 PSP 指向任务栈的栈顶。当调用 bxr14 指令后,系统以 PSP 作为 SP 指针出栈,把接下来要运行的新任务的任务栈中剩下的内容加载到 CPU 寄存器:R0(任务形参)、R1、R2、R3、R12、R14(LR)、R15(PC)和 xPSR,从而切换到新的任务。
main函数
任务的创建,就绪列表的实现,调度器的实现均已经说完,现在我们把全部的测试代码都放到main.c 里面。
/*
*************************************************************************
* 包含的头文件
*************************************************************************
*/
#include "FreeRTOS.h"
#include "task.h"
/*
*************************************************************************
* 全局变量
*************************************************************************
*/
portCHAR flag1;
portCHAR flag2;
extern List_t pxReadyTasksLists[ configMAX_PRIORITIES ];
/*
*************************************************************************
* 任务控制块 & STACK
*************************************************************************
*/
TaskHandle_t Task1_Handle;
#define TASK1_STACK_SIZE 20
StackType_t Task1Stack[TASK1_STACK_SIZE];
TCB_t Task1TCB;
TaskHandle_t Task2_Handle;
#define TASK2_STACK_SIZE 20
StackType_t Task2Stack[TASK2_STACK_SIZE];
TCB_t Task2TCB;
/*
*************************************************************************
* 函数声明
*************************************************************************
*/
void delay (uint32_t count);
void Task1_Entry( void *p_arg );
void Task2_Entry( void *p_arg );
/*
************************************************************************
* main函数
************************************************************************
*/
int main(void)
{
/* 初始化与任务相关的列表,如就绪列表 */
prvInitialiseTaskLists();
/* 创建任务 */
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 ) );
Task2_Handle = xTaskCreateStatic( ( TaskFunction_t )Task2_Entry,
(char *)"Task2",
(uint32_t)TASK2_STACK_SIZE,
(void *)NULL,
(StackType_t *)Task2Stack,
(TCB_t *)&Task2TCB);
/* 将任务添加到就绪列表 */
vListInsertEnd( &( pxReadyTasksLists[2]), &( ((TCB_t *)(&Task2TCB))->xStateListItem ) );
/* 启动调度器,开始多任务调度,启动成功则不返回 */
vTaskStartScheduler();
for(;;)
{
/*啥事不干*/
}
}
/*
*************************************************************************
* 函数实现
*************************************************************************
*/
/* 软件延时 */
void delay (uint32_t count)
{
for(; count!=0; count--);
}
/* 任务1 */
void Task1_Entry( void *p_arg )
{
for( ;; )
{
flag1 = 1;
delay( 100 );
flag1 = 0;
delay( 100 );
/* 任务切换,这里是手动切换 */
taskYIELD();
}
}
/* 任务2 */
void Task2_Entry( void *p_arg )
{
for( ;; )
{
flag2 = 1;
delay( 100 );
flag2 = 0;
delay( 100 );
/* 任务切换,这里是手动切换 */
taskYIELD();
}
}
因为目前还不支持优先级,每个任务执行完毕之后都主动调用任务切换函数 taskYIELD() 来实现任务的切换。
实验现象
软件调试仿真,具体过程:
- Debug 按钮进入调试界面
- 逻辑分析仪按钮调出逻辑分析仪
- 要观察的变量添加到逻辑分析仪 变量设置为 Bit 模式,默认是 Analog
- 点击全速运行按钮,即可看到波形,Zoom 栏的 In Out All 可放大和缩小波形
只是把本节的内容看完,然后再仿真看看波形是远远不够的,应该是把当前任务控制块指针pxCurrentTCB、就绪列表 pxReadyTaskLists、每个任务的控制块和任务的栈这些变量统统添加到观察窗口,然后单步执行程序,看看这些变量是怎么变化的。特别是任务切换时,CPU 寄存器、任务栈和 PSP 这些是怎么变化的,让机器执行代码的过程在自己的脑子里面过一遍。
本节涉及的汇编指令讲解
本节中有些函数是用汇编编写的,涉及的 ARM 汇编指令具体参考下表
指令名称作用 | 作用 |
---|---|
EQU | 给数字常量取一个符号名,相当于 C 语言中的define |
AREA | 汇编一个新的代码段或者数据段 |
SPACE | 分配内存空间 |
PRESERVE8 | 当前文件栈需按照 8 字节对齐 |
EXPORT | 声明一个标号具有全局属性,可被外部的文件使用 |
DCD | 以字为单位分配内存,要求 4 字节对齐,并要求初始化这些内存 |
PROC | 定义子程序,与 ENDP 成对使用,表示子程序结束 |
WEAK | 弱定义,如果外部文件声明了一个标号,则优先使用外部文件定义的标号,如果外部文件没有定义也不出错。要注意的是:这个不是ARM的指令,是编译器的,这里放在一起只是为了方便。 |
IMPORT | 声明标号来自外部文件,跟 C 语言中的 EX-TERN 关键字类似 |
B | 跳转到一个标号 |
ALIGN | 编译器对指令或者数据的存放地址进行对齐,一般需要跟一个立即数,缺省表示 4 字节对齐。要注意的是:这个不是 ARM 的指令,是编译器的,这里放在一起只是为了方便。 |
END | 到达文件的末尾,文件结束 |
IF,ELSE,ENDIF | 汇编条件分支语句,跟 C 语言的 if else 类似 |
MRS | 加载特殊功能寄存器的值到通用寄存器 |
MSR | 存储通用寄存器的值到特殊功能寄存器 |
CBZ | 比较,如果结果为 0 就转移 |
CBNZ | 比较,如果结果非 0 就转移 |
LDR | 从存储器中加载字到一个寄存器中 |
LDR[伪指令] 加 | 一个立即数或者一个地址值到一个寄存器 |
LDRH | 从存储器中加载半字到一个寄存器中 |
LDRB | 从存储器中加载字节到一个寄存器中 |
STR | 把一个寄存器按字存储到存储器中 |
STRH | 把一个寄存器存器的低半字存储到存储器中 |
STRB | 把一个寄存器的低字节存储到存储器中 |
ORR | 按位或 |
BX | 直接跳转到由寄存器给定的地址 |
BL | 跳转到标号对应的地址,并且把跳转前的下条指令地址保存到 LR |
BLX | 跳转到由寄存器 REG 给出的的地址,并根据REG 的 LSB 切换处理器状态,还要把转移前的下条指令地址保存到 LR。ARM(LSB=0),Thumb(LSB=1)。CM3 只在 Thumb 中运行,就必须保证 reg 的 LSB=1,否则一个 fault 打过来 |
参考资料:《FreeRTOS 内核实现与应用开发实战—基于RT1052》