在上一章节中,任务体内的延时使用的是软件延时,即还是让 CPU 空等来达到延时的效果。使用 RTOS 的很大优势就是榨干 CPU 的性能,永远不能让它闲着,任务如果需要延时也就不能再让 CPU 空等来实现延时的效果。
RTOS 中的延时叫阻塞延时,即任务需要延时的时候,任务会放弃 CPU的使用权,CPU可以去干其它的事情,当任务延时时间到,重新获取 CPU使用权,任务继续运行,这样就充分地利用了 CPU的资源,而不是干等着。
当任务需要延时,进入阻塞状态,那 CPU 又去干什么事情了?如果没有其它任务可以运行,RTOS 都会为 CPU 创建一个空闲任务,这个时候 CPU 就运行空闲任务。
空闲任务的优先级是最低的,主要是做一些系统内存的清理工作。空闲任务是系统在【启动调度器】的时候创建的,而不是在主函数一开始,和任务函数一起创建,这点要比较注意。
接下来,实现一个空闲任务:
1 定义空闲任务的栈
StackType_t IdleTaskStack[configMINIMAL_STACK_SIZE];
2 定义空闲任务的任务控制块
TCB_t IdleTaskTCB;
3 定义空闲任务函数主体
static portTASK_FUNCTION( prvIdleTask, pvParameters )
{
/* 防止编译器的警告 */
( void ) pvParameters;
for(;;)
{
/* 空闲任务暂时什么都不做 */
}
}
4 创建空闲任务(在启动调度器时创建的)
void vTaskStartScheduler( void )
{
/*======================================创建空闲任务start==============================================*/
TCB_t *pxIdleTaskTCBBuffer = NULL; /* 用于指向空闲任务控制块 */
StackType_t *pxIdleTaskStackBuffer = NULL; /* 用于空闲任务栈起始地址 */
uint32_t ulIdleTaskStackSize;
/* 获取空闲任务的内存:任务栈和任务TCB */
vApplicationGetIdleTaskMemory( &pxIdleTaskTCBBuffer,
&pxIdleTaskStackBuffer,
&ulIdleTaskStackSize );
xIdleTaskHandle = xTaskCreateStatic( (TaskFunction_t)prvIdleTask, /* 任务入口 */
(char *)"IDLE", /* 任务名称,字符串形式 */
(uint32_t)ulIdleTaskStackSize , /* 任务栈大小,单位为字 */
(void *) NULL, /* 任务形参 */
(StackType_t *)pxIdleTaskStackBuffer, /* 任务栈起始地址 */
(TCB_t *)pxIdleTaskTCBBuffer ); /* 任务控制块 */
/* 将任务添加到就绪列表 */
vListInsertEnd( &( pxReadyTasksLists[0] ), &( ((TCB_t *)pxIdleTaskTCBBuffer)->xStateListItem ) );
/*======================================创建空闲任务end================================================*/
/* 手动指定第一个运行的任务 */
pxCurrentTCB = &Task1TCB;
/* 初始化系统时基计数器 */
xTickCount = ( TickType_t ) 0U;
/* 启动调度器 */
if( xPortStartScheduler() != pdFALSE )
{
/* 调度器启动成功,则不会返回,即不会来到这里 */
}
}
在调用创建函数前,先通过了一个函数来获取空闲任务的内存。这个实在是太简单,跟之前创建任务函数没有什么区别,我感觉就是多此一举。但归根结底,就是赋值吧,把前面3个过程创建的任务栈、任务控制块和空闲任务函数主体传入任务创建函数。然后把空闲任务加载到就绪列表,空闲任务在就绪列表内是最低优先级(数组下标为0)。
实现阻塞延时
阻塞延时的阻塞是指任务调用该延时函数后,任务会被剥离 CPU 使用权,然后进入阻塞状态,直到延时结束,任务重新获取 CPU 使用权才可以继续运行。在任务阻塞的这段时间,CPU可以去执行其它的任务,如果其它的任务也在延时状态,那么 CPU就将运行空闲任务。
当任务1在阻塞延时,调度器会尝试运行任务2;此时如果任务2也在阻塞延时,调度器就会去运行空闲任务。
延时阻塞函数vTaskDelay(),先设置延时时间,那么它都进入延时了,肯定要切换任务啊:
void vTaskDelay( const TickType_t xTicksToDelay )
{
TCB_t *pxTCB = NULL;
/* 获取当前任务的TCB */
pxTCB = pxCurrentTCB;
/* 设置延时时间 */
pxTCB->xTicksToDelay = xTicksToDelay;
/* 任务切换 */
taskYIELD();
}
为了记录任务需要延时的时间,我们需要在任务控制块中添加一个成员xTicksToDelay,单位是SysTick的中断周期。在野火的书籍中,SysTick的中断周期设置为10ms,那么调用vTaskDelay(2)就会延时2 * 10ms。此时,任务控制块的完整定义为:
typedef struct tskTaskControlBlock
{
volatile StackType_t *pxTopOfStack; /* 栈顶 */
ListItem_t xStateListItem; /* 任务节点 */
StackType_t *pxStack; /* 任务栈起始地址 */
/* 任务名称,字符串形式 */
char pcTaskName[ configMAX_TASK_NAME_LEN ];
TickType_t xTicksToDelay; /* 用于延时 */
} tskTCB;
typedef tskTCB TCB_t;
在之前,任务选择函数很简单,就是在两个任务之间来回切换。而现在,需要增加一个空闲任务,让pxCurrentTCB在这三个任务中切换,需要修改算法:
vTaskSwitchContext()任务切换函数:
void vTaskSwitchContext( void )
{
/* 如果当前线程是空闲线程,那么就去尝试执行线程1或者线程2,
看看他们的延时时间是否结束,如果线程的延时时间均没有到期,
那就返回继续执行空闲线程 */
if( pxCurrentTCB == &IdleTaskTCB )
{
if(Task1TCB.xTicksToDelay == 0)
{
pxCurrentTCB =&Task1TCB;
}
else if(Task2TCB.xTicksToDelay == 0)
{
pxCurrentTCB =&Task2TCB;
}
else
{
return; /* 线程延时均没有到期则返回,继续执行空闲线程 */
}
}
else
{
/*如果当前线程是线程1或者线程2的话,检查下另外一个线程,如果另外的线程不在延时中,就切换到该线程
否则,判断下当前线程是否应该进入延时状态,如果是的话,就切换到空闲线程。否则就不进行任何切换 */
if(pxCurrentTCB == &Task1TCB)
{
if(Task2TCB.xTicksToDelay == 0)
{
pxCurrentTCB =&Task2TCB;
}
else if(pxCurrentTCB->xTicksToDelay != 0)
{
pxCurrentTCB = &IdleTaskTCB;
}
else
{
return; /* 返回,不进行切换,因为两个线程都处于延时中 */
}
}
else if(pxCurrentTCB == &Task2TCB)
{
if(Task1TCB.xTicksToDelay == 0)
{
pxCurrentTCB =&Task1TCB;
}
else if(pxCurrentTCB->xTicksToDelay != 0)
{
pxCurrentTCB = &IdleTaskTCB;
}
else
{
return; /* 返回,不进行切换,因为两个线程都处于延时中 */
}
}
}
}
这个函数实现的功能就是:如果当前任务是空闲任务,那么就去尝试执行任务 1 或者任务 2,看看他们的延时时间是否结束,如果任务的延时时间均没有到期,那就返回继续执行空闲任务。
如果当前任务是任务 1 或者任务 2 的话,检查下另外一个任务,如果另外的任务不在延时中,就切换到该任务。否则,判断下当前任务是否应该进入延时状态,如果是的话,就切换到空闲任务,否则就不进行任何切换 。
这里有个地方值得注意(所有任务控制块的xTicksToDelay在初始化时都为0,这样才能保证一开始,所有任务同时进行):当开启调度器时,首先会选择要执行的第一个任务,如下:
/* 任务1 */
void Task1_Entry( void *p_arg )
{
for( ;; )
{
flag1 = 1;
vTaskDelay( 2 );
flag1 = 0;
vTaskDelay( 2 );
}
}
该任务的执行流程如下:
1 设置flag = 1;
2 调用延时堵塞函数,该延时函数首先获取当前的任务控制块(任务1),然后把任务控制块内的延时时间(从初始值的0)设置为2个systick周期,再开启任务切换;
3 进入到任务切换,当前任务是任务1,那个调度器回去查询任务块2的xTicksToDelay(初始化时就为0),满足要求,就会切换到任务2去执行;
4 任务2 先设置flag2 = 1,也会把延时时间从(初始值0)设置为2个systick周期,然后开启任务切换;
5 此时,两个任务都在延时(xTicksToDelay都为2),自动切换到空闲函数;
那么,如果我们把任务控制块2的xTicksToDelay初始值从0改为1呢?可以想到的是,任务函数2不再和任务函数1同时执行,而是会延迟一个systick周期。
接下来的一个问题,每个任务的延时时间xTicksToDelay在哪里递减?我们说到,这个延时时间是多少个systick周期,那肯定就是在systick的中断服务函数中啦
/*
*************************************************************************
* SysTick中断服务函数
*************************************************************************
*/
void xPortSysTickHandler( void )
{
/* 关中断 */
vPortRaiseBASEPRI();
/* 更新系统时基 */
xTaskIncrementTick();
/* 开中断 */
vPortClearBASEPRIFromISR();
}
首先是关掉中断,防止其他中断抢占(如外部中断,PendSV),影响系统时钟的准确度。然后更新时基,再开中断。这里,起始就是把设置了一个临界段,用来保护更新时基的函数。
更新系统时基的函数:
void xTaskIncrementTick( void )
{
TCB_t *pxTCB = NULL;
BaseType_t i = 0;
/* 更新系统时基计数器xTickCount,xTickCount是一个在port.c中定义的全局变量 */
// xTickCount是一个系统计数器,用于记录当前系统跑了多久
const TickType_t xConstTickCount = xTickCount + 1;
xTickCount = xConstTickCount;
/* 扫描就绪列表中所有线程的xTicksToDelay,如果不为0,则减1 */
for(i=0; i<configMAX_PRIORITIES; i++)
{
// 从就绪列表中找到一个任务控制块,然后判断xTicksToDelay是否 = 0
pxTCB = ( TCB_t * ) listGET_OWNER_OF_HEAD_ENTRY( ( &pxReadyTasksLists[i] ) );
if(pxTCB->xTicksToDelay > 0)
{
pxTCB->xTicksToDelay --;
}
}
/* 任务切换 */
portYIELD();
}
截至目前,已经定义好了systick的中断函数,但还没有开启systick,所以,接下来要进行systick的初始化:
/*
*************************************************************************
* 初始化SysTick
*************************************************************************
*/
void vPortSetupTimerInterrupt( void )
{
/* 设置重装载寄存器的值 */
// 系统时钟为25Mhz,TICK_RATE_HZ为100,那么就是1秒钟执行100次中断,定时时间为10ms
portNVIC_SYSTICK_LOAD_REG = ( configSYSTICK_CLOCK_HZ / configTICK_RATE_HZ ) - 1UL;
/* 设置系统定时器的时钟等于内核时钟
使能SysTick 定时器中断
使能SysTick 定时器 */
portNVIC_SYSTICK_CTRL_REG = ( portNVIC_SYSTICK_CLK_BIT |
portNVIC_SYSTICK_INT_BIT |
portNVIC_SYSTICK_ENABLE_BIT );
}
首先,是设置重装在寄存器的值:系统时钟为25Mhz(软件仿真),TICK_RATE_HZ为100,那么就是1秒钟执行100次中断,定时时间为10ms,然后使能。这个systick初始化函数在哪里被调用呢?事实上,是在调度器启动函数里面,在启动第一个任务前被调用,开启时钟。
/*
*************************************************************************
* 调度器启动函数
*************************************************************************
*/
BaseType_t xPortStartScheduler( void )
{
/* 配置PendSV 和 SysTick 的中断优先级为最低 */
portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;
/* 初始化SysTick */
vPortSetupTimerInterrupt();
/* 启动第一个任务,不再返回 */
prvStartFirstTask();
/* 不应该运行到这里 */
return 0;
}
最终,程序的一个运行结果如下:
分析一下,首先执行任务1,flag1 = 1,然后设置堵塞延时,并切换任务,此时,任务选择函数发现任务2的延时值为0(默认复位值就是0),就转去执行任务2,任务2会把flag2 = 1,也开始延时并切换任务,接下来就进入空闲任务。这里,由于两个任务控制块的xTicksToDelay均为默认值(0),所以任务1第一次切换就到了任务2,两个任务是同时发生的。
还可以测试一下,把任务2的xTicksToDelay初始值设置为1,如下
Task2TCB.xTicksToDelay = 1;
那么此时, 任务2应该会比任务1延迟执行一个systick周期,测试一下:
果然,任务2延迟了1个systick周期才执行,程序正确。