在一个裸机系统中,如果有全局变量,有子函数调用,有中断发生。他们统统放在一个叫栈的地方,栈是单片机 RAM 里面一段连续的内存空间,栈的大小一般在启动文件或者链接脚本里面指定,最后由 C库函数_main 进行初始化。
但是,在多任务系统中,每个任务都是独立的,互不干扰的,所以要为每个任务都分配独立的栈空间,这个栈空间通常是一个预先定义好的全局数组,也可以是动态分配的一段内存空间,但它们都存在于 RAM中。
我们来定义两个任务栈(静态):
#define TASK1_STACK_SIZE 20
StackType_t Task1Stack[TASK1_STACK_SIZE];
#define TASK2_STACK_SIZE 20
StackType_t Task2Stack[TASK2_STACK_SIZE];
然后定义两个任务(无限循环的函数):
void Task1_Entry( void *p_arg )
{
for( ;; )
{
flag1 = 1;
delay( 100 );
flag1 = 0;
delay( 100 );
}
}
/* 任务2 */
void Task2_Entry( void *p_arg )
{
for( ;; )
{
flag2 = 1;
delay( 100 );
flag2 = 0;
delay( 100 );
}
}
栈空间和任务函数都定义好了,怎么把这两个关联起来?在RTOS中,任务的执行由系统调度。系统为每个任务都定义了任务控制块,里面存有任务的各种信息(栈顶、任务节点、栈底和任务名称)。系统对任务的全部操作,均由任务控制块完成。
typedef struct tskTaskControlBlock
{
volatile StackType_t *pxTopOfStack; /* 栈顶(栈的最大地址 = 栈底 + 栈空间 - 1) */
ListItem_t xStateListItem; /* 任务节点 */
StackType_t *pxStack; /* 任务栈起始地址,也就是栈底(栈的最小地址) */
/* 任务名称,字符串形式 */
char pcTaskName[ configMAX_TASK_NAME_LEN ];
} tskTCB;
typedef tskTCB TCB_t;
我们来定义两个任务控制块:
TCB_t Task1TCB;
TCB_t Task2TCB;
接下来,我们怎么将任务栈,任务函数和任务控制块关联起来?这个工作由任务创建函数来完成。
TaskHandle_t xTaskCreateStatic( TaskFunction_t pxTaskCode, /* 任务入口(任务函数名) */
const char * const pcName, /* 任务名称,字符串形式 */
const uint32_t ulStackDepth, /* 任务栈大小,单位为字(用于计算栈顶) */
void * const pvParameters, /* 任务形参(通常为NULL) */
StackType_t * const puxStackBuffer, /* 任务栈起始地址(定义的全局数组) */
TCB_t * const pxTaskBuffer ) /* 任务控制块指针 */
{
TCB_t *pxNewTCB; // 新建一个任务控制块指针
// 创建一个void *类型的指针,这种指针只保存地址,到使用时再可以随意的进行强制类型转换成其他类型的指针
TaskHandle_t xReturn; // 任务句柄
if( ( pxTaskBuffer != NULL ) && ( puxStackBuffer != NULL ) )
{
// STM32的栈,是由栈顶向下生长
pxNewTCB = ( TCB_t * ) pxTaskBuffer; // 把任务控制块指针指向传入参数的任务控制块
pxNewTCB->pxStack = ( StackType_t * ) puxStackBuffer; // 获得任务控制块的栈底地址(栈底设置完毕)
/* 创建新的任务 */
prvInitialiseNewTask( pxTaskCode, /* 任务入口 */
pcName, /* 任务名称,字符串形式 */
ulStackDepth, /* 任务栈大小,单位为字 */
pvParameters, /* 任务形参 */
&xReturn, /* 任务句柄 */
pxNewTCB); /* 任务栈起始地址 */
}
else
{
xReturn = NULL;
}
/* 返回任务句柄,如果任务创建成功,此时xReturn应该指向任务控制块 */
return xReturn;
}
这个函数中,由很多地方值得注意。首先,需要我们传入任务栈起始地址(就是我们定义的任务栈数组地址),然后呢,还需要传入一个任务控制块。任务控制块结构体的第三个元素,就是任务栈指针(起始地址)。这个函数,只完成了一个功能,就是把任务栈和控制块的地址关联,接下来调用任务初始化函数(我提前猜测,它应该会完成控制块中的其他3个参数)。
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;
/* 获取栈顶地址,入栈时,程序计数器PC指针由栈顶往下生长 */
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 ); // 任务节点设置完毕
/* 初始化任务栈,就是入栈 */ // 这是最后一项,设置栈顶地址,
//此时入栈完成(保存了任务函数),psp指针可以保存R4寄存器的值
pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters );
/* 让任务句柄指向任务控制块 */
// 此时,句柄中保存的是已经初始化ok的任务控制块的地址,
//该任务控制块已经将定义的任务栈和任务控制块关联完成
if( ( void * ) pxCreatedTask != NULL )
{
*pxCreatedTask = ( TaskHandle_t ) pxNewTCB;
}
}
这个函数,一看就很复杂。主要分成两部分来分析。
第一部分,函数要求传入任务控制块、栈名称、栈大小。由于在上一个函数,我们已经把栈底地址传到了控制块内,所以,首先就是根据栈底地址和栈大小计算出任务栈顶地址(8字节对齐),传入到TCB中;然后,把名字也传入到TCB中。最后,控制块内还有一个列表节点,初始化该节点并把设置拥有者该任务控制块。那么,控制块的4个参数就全部赋值完毕了。
但是,似乎还少了点什么?仔细想想,截至目前,我们所做的工作是,把定义的栈空间、栈大小、字符串给到了控制块,但是,好像并没有怎么把控制块和任务函数联系起来啊,然后就是第二步,这个函数所做的工作。
这个函数,传入了栈顶地址、任务函数地址和任务形参(先忽略),我们可以想到的就是,如果我们在任务栈中保存任务函数地址(入栈),那么一旦这个栈出栈,就会去到保存的任务函数来执行,这样就实现了关联。
/* 初始化任务栈,就是入栈 */
// 这是最后一项,设置栈顶地址,此时入栈完成(保存了任务函数),psp指针可以保存R4寄存器的值
pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters );
ARM_CM3权威指南中关于中断 / 异常的说明:
分析源码:
/*
调用函数pxPortInitialiseStack()后,相当于执行了一次系统节拍时钟中断:将一些重要寄存器入栈。
虽然任务还没开始执行,也并没有中断发生,但看上去就像寄存器已经被入栈了,并且部分堆栈值被修改成了我们需要的已知值。
对于不同的硬件架构,入栈的寄存器也不相同,所以我们看到这个函数是由移植层提供的。对于Cortex-M3架构,
需要依次入栈xPSR、PC、LR、R12、R3~R0、R11~R4,假设堆栈是向下生长的,初始化后的堆栈如图3-1所示。
在图3-1中我们看到寄存器xPSR被初始为0x01000000,其中bit24被置1,表示使用Thumb指令;
寄存器PC被初始化为任务函数指针vTask_A,这样当某次任务切换后,任务A获得CPU控制权,
任务函数vTask_A被出栈到PC寄存器,之后会执行任务A的代码
*/
// 这个初始化函数,就是把传入的任务函数地址,保存在PC指针内,然后把栈顶指针指向R4寄存器
// 入栈操作,把传入的任务函数保存在任务栈空间,然后把栈顶指针指向R4(最低位寄存器),便于出栈
StackType_t *pxPortInitialiseStack( StackType_t *pxTopOfStack, TaskFunction_t pxCode, void *pvParameters )
{
/* 异常发生时,自动加载到CPU寄存器的内容 */
// 异常发生时,把CPU寄存器中的值保存到栈内,从栈顶依次往下递减
pxTopOfStack--;
// xPSR寄存器是状态字寄存器,在ARM_CM3中,位24必须为1
*pxTopOfStack = portINITIAL_XPSR; /* xPSR的bit24必须置1 */
pxTopOfStack--;
// 保存PC指针的值,记录CPU当前正在执行的程序
*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK; /* PC,即任务入口函数 */
pxTopOfStack--;
*pxTopOfStack = ( StackType_t ) prvTaskExitError; /* LR,函数返回地址 */
// 这一句,直接地址-5,就是跳到R0寄存器,而R12, R3, R2 R1 默认初始化为0
pxTopOfStack -= 5; /* R12, R3, R2 and R1 默认初始化为0 */
*pxTopOfStack = ( StackType_t ) pvParameters; /* R0,任务形参 */
/* 异常发生时,手动加载到CPU寄存器的内容 */
// 这一步直接减8,此时栈顶指针指向了空闲堆栈(在栈前面几个字节中保存了一些状态)
pxTopOfStack -= 8; /* R11, R10, R9, R8, R7, R6, R5 and R4默认初始化为0 */
/* 返回栈顶指针,此时pxTopOfStack指向空闲栈 */
return pxTopOfStack;
}
我们以图来方便理解:
一开始,栈顶指针-1,然后栈保存xPSR,然后再-1,这里就非常重要(实际上是把传入的任务函数,保存在对应的任务栈内,出栈时执行):
// 保存PC指针的值,记录CPU当前正在执行的程序
// 这里实际上是把传入的任务函数,保存在对应的任务栈内,出栈时执行
*pxTopOfStack = ( ( StackType_t ) pxCode ) & portSTART_ADDRESS_MASK;
/* PC,即任务入口函数 */
最后,把栈顶指针-8,指向R4(本质上,是出栈时从这个指针开始,把对应的值加载到R4...寄存器中)。
这样,整个任务的创建就完成了。我们做了很多事情,把零散定义的任务栈、任务函数、任务控制块都关联了起来。任务控制块中,保存了任务栈和任务函数的全部信息,而任务栈内,又保存了任务函数的地址(出栈时执行函数)。最后,返回一个void *类型的句柄,指向任务控制块。
任务定义好了,接下来就是把任务插入到就绪列表中,这个太简单了,就是把任务控制块中的列表项插入到列表中。
/* 将任务添加到就绪列表 */
vListInsertEnd( &( pxReadyTasksLists[1] ), &( ((TCB_t *)(&Task1TCB))->xStateListItem ) );
接下来,就是最难最难的部分,实现调度器:
先要定义一个控制块,记录当前正在执行的任务:
void vTaskStartScheduler( void )
{
/* 手动指定第一个运行的任务 */
pxCurrentTCB = &Task1TCB;
/* 启动调度器 */
if( xPortStartScheduler() != pdFALSE )
{
/* 调度器启动成功,则不会返回,即不会来到这里 */
}
}
然后看启动调度器的函数,我们先合理猜测一下:在之前创建任务时,我们已经把任务函数保存在了任务栈内,那么调度过程,应该会有一个从当前任务栈内,把栈保存的值出栈加载到CPU内,然后执行任务函数,预计会有这么一个过程:
启动调度器的函数,起始就是启动第一个任务(出栈):
BaseType_t xPortStartScheduler( void )
{
/* 配置PendSV 和 SysTick 的中断优先级为最低 */
portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;
/* 启动第一个任务,不再返回 */
prvStartFirstTask();
/* 不应该运行到这里 */
return 0;
}
然后来看,启动第一个任务的函数,应该会启动一个中断,然后在中断服务函数中出栈,这样就能顺利执行一个任务函数:
/*
* 参考资料《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的地址 */
// 在中断向量表中,0地址存放的是栈顶地址(而不是栈顶地址为0),偏移0X04存放复位程序的地址,依次往下
ldr r0, =0xE000ED08 // 把这个立即数加载到R0
ldr r0, [r0] // 把R0地址指向的内容(SCB_VTOR寄存器值 = 0)保存在R0,此时R0的值为0,就是0地址
ldr r0, [r0] // 把0地址指向的内容(栈顶指针),加载到R0
/* 设置主堆栈指针msp的值 */
msr msp, r0 // 把R0的值存储到主堆栈指针MSP
/* 使能全局中断 */
cpsie i // 开中断和异常
cpsie f
dsb
isb
/* 调用SVC去启动第一个任务 */
svc 0 // 开启SVC中断(向量表中偏移地址为2C,系统服务调用),接下来执行SVC中断服务函数
nop
nop
}
果然,MSP是主堆栈指针,然后去到中断服务函数:
这一段汇编代码,就是找到当前执行的任务控制块,然后加载栈顶指针(此时还是初始化时的状态 -8,指向R4),然后连续手动加载8个字节到CPU内部寄存器,此时栈顶指针指向了自动加载寄存器的第一个,我们把栈指针更新到psp(因为前面已经使用了msp),然后开中断,结束中断服务函数(自动从栈中加载CPU寄存器,psp也不断更新,一直到任务栈栈顶)
__asm void vPortSVCHandler( void )
{
extern pxCurrentTCB;
PRESERVE8
// 把pxCurrentTCB控制块的地址加载到R3
ldr r3, =pxCurrentTCB /* 加载pxCurrentTCB的地址到r3 */
// 把R3指向的内容(pxCurrentTCB)加载到R1
ldr r1, [r3] /* 加载pxCurrentTCB到r1 */
// 把R1指向的内容加载到R0,TCB控制块的第一个元素就是栈顶指针
// 在任务初始化后,栈顶指针指向R4寄存器(前面自动保存8个,然后自减8)
ldr r0, [r1] /* 加载pxCurrentTCB指向的值到r0,目前r0的值等于第一个任务堆栈的栈顶 */
// 类似于出栈,把栈中的内容加载到R4 ~ R11寄存器
ldmia r0!, {r4-r11} /* 以r0为基地址,将栈里面的内容加载到r4~r11寄存器,同时r0会递增 */
msr psp, r0 /* 将r0的值,即任务的栈指针更新到psp */
// 此时,栈指针指向第一个自动加载值寄存器(这样可以实现栈中剩下内容自动加载到CPU)
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的值也将更新,即指向任务栈的栈顶 */
}
这样,就实现了一个任务的启动。
任务调度
任务调度起始就是实现在多个任务中进行切换。怎么切换?我先猜想一下,在此之前,系统已经启动了第一个任务,而每个人物都是独立的无限循环,理论上来说,它没有办法跳到另一个任务中。那我们怎么办?可以有一个办法,在第一个任务(已经启动)的结尾语句,来触发一个PendSV中断,这样,就会从任务的执行跳转到PendSV的中断服务函数,而在中断服务函数内,我们先把当前正在执行的任务函数1入栈保存(暂停运行,保存现场),然后调用任务切换函数(主要是获取下一个要执行的任务控制块),获取到下一个任务的堆栈指针,然后出栈(这样,就切换到了第二个任务)。同样的,在第二个任务末尾也应该有一个来触发一个PendSV中断,以便跳转到第一个任务。这样,就实现了任务的来回切换。
那个,我们来修改任务函数(添加PendSV中断启动语句):
/* 任务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()这个函数,就是简单地触发PendSV中断(设置中断位),很简单:
#define portYIELD() \
{ \
/* 触发PendSV,产生上下文切换 */ \
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \
__dsb( portSY_FULL_READ_WRITE ); \
__isb( portSY_FULL_READ_WRITE ); \
}
然后,任务1运行到结尾处,就会跳转到PendSV的中断服务函数(实现任务切换):
__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
// 这里,pxCurrentTCB还没有切换,因为是在当前任务函数的中断服务内,这里所做的,就是入栈
// 之前说到,一旦触发异常,CPU就会自动的保存8个寄存器值到栈内,执行到这里时,已经保存了前8个
ldr r3, =pxCurrentTCB /* 加载pxCurrentTCB的地址到r3 */
// 这里,R2寄存器保存的是当前任务控制块
ldr r2, [r3] /* 加载pxCurrentTCB到r2 */
// 以 r0 作为基址 指针先递减,再操作,将 CPU寄存器 r4~r11的值存储到任务栈,同时更新 r0的值
stmdb r0!, {r4-r11} /* 将CPU寄存器r4~r11的值存储到r0指向的地址 */
/*
将寄存器 R0 的值写入到寄存器 R2 所保存的地址中去,也就是将新的栈顶保存在任务
控制块的第一个字段中。此时的寄存器 R0 保存着最新的堆栈栈顶指针值,所以要将这个最新
的栈顶指针写入到当前任务的任务控制块第一个字段,而经过(2)和(3)已经获取到了任务控制块,
并将任务控制块的首地址写如到了寄存器 R2 中。
将 r0的值存储到 r2指向的内容,r2等于 pxCurrentTCB。具体为将r0 的值存储到上一个任务的栈顶指针 pxTopOfStack.
*/
str r0, [r2] /* 将任务栈的新的栈顶指针存储到当前任务TCB的第一个成员,即栈顶指针 */
// 这里,把R3和R14临时压入堆栈,为什么?因为前面有一句ldr r3, =pxCurrentTCB /* 加载pxCurrentTCB的地址到r3 */
stmdb sp!, {r3, r14} /* 将R3和R14临时压入堆栈,因为即将调用函数vTaskSwitchContext,
调用函数时,返回地址自动保存到R14中,所以一旦调用发生,R14的值会被覆盖,因此需要入栈保护;
R3保存的当前激活的任务TCB指针(pxCurrentTCB)地址,函数调用后会用到,因此也要入栈保护 */
mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY /* 进入临界段 */
msr basepri, r0
dsb
isb
// 这里是调用任务切换函数,会把新的任务控制块赋值给pxCurrentTCB,此时pxCurrentTCB的值已经被修改
// 而R3寄存器指向了pxCurrentTCB,所以,再次加载R3寄存器时,就会读取到新的任务控制块
bl vTaskSwitchContext /* 调用函数vTaskSwitchContext,寻找新的任务运行,通过使变量pxCurrentTCB指向新的任务来实现任务切换 */
mov r0, #0 /* 退出临界段 */
msr basepri, r0
ldmia sp!, {r3, r14} /* 恢复r3和r14 */
/* 加载 r3 指向的内容到 r1。r3 存放的是 pxCurrentTCB 的地址,即
让 r1 等于 pxCurrentTCB。pxCurrentTCB 在上面的 vTaskSwitchContext 函数中被更新,指
向了下一个将要运行的任务的 TCB。*/
ldr r1, [r3]
// 这里的栈顶,是初始化或入栈的栈顶(也就是真正的栈顶 - 自动保存的8个,再 - 8)
ldr r0, [r1] /* 当前激活的任务TCB第一项保存了任务堆栈的栈顶,现在栈顶值存入R0*/
ldmia r0!, {r4-r11} /* 出栈 */
msr psp, r0
isb
// 出栈,自动恢复
bx r14 /* 异常发生时,R14中保存异常返回标志,包括返回后进入线程模式还是处理器模式、
使用PSP堆栈指针还是MSP堆栈指针,当调用 bx r14指令后,硬件会知道要从异常返回,
然后出栈,这个时候堆栈指针PSP已经指向了新任务堆栈的正确位置,
当新任务的运行地址被出栈到PC寄存器后,新的任务也会被执行。*/
nop
}
这个中断服务函数,主要是实现两个功能。
第一,把当前运行的控制块地址保存在R3寄存器中,然后入栈保存现场,再把R3和R14压栈。其中,R3一直指向当前运行的控制块,R14保存异常返回地址。
第二,调用任务切换函数,把当前控制块切到下一个任务。然后恢复R3,此时R3指向了新的任务,并从R3开始出栈,知道执行程序。最后,从r14记录的异常地址返回,结束中断服务程序。此时,就切换到了任务2。
仿真结果: