【学习日记】【FreeRTOS】空闲任务与阻塞延时

写在前面

本文是基于野火 RTOS 教程对空闲任务和阻塞延时的详解。

一、什么是任务中的阻塞延时

  • 说到阻塞延时,笔者的第一反应就是在单片机的 while 循环中,使用一个 for 循环不断递减一个大数,通过 CPU 不断执行一条指令的耗时进行延时。这种延时会占用 CPU 资源执行指令,在延时的时候 CPU 不能执行其他的指令。
  • 但是注意,我们现在是想在 RTOS 中的任务实现阻塞延时,RTOS 可以有多个任务,所有所谓任务中的阻塞延时虽然也是阻塞其后的代码运行,但是只阻塞了他所在的那个任务中阻塞延时函数后面的代码。
  • 也就是说,RTOS 中,任务中的阻塞延时就是先阻塞一下这个任务,然后把 CPU 使用权交给其他代码,虽然也是阻塞下文的代码执行,但是只阻塞这个任务的下文,CPU 在这个过程中可以执行其他任务中的指令,大大提高 CPU 利用率,和笔者印象中的阻塞延时并不一样。

二、空闲任务有什么用

  • 空闲任务的优先级是所有任务中优先级最低的,当其他任务都在阻塞延时中,CPU 就会切换到空闲任务运行。
  • 一般来说在空闲任务里面运行一些系统内存的清理工作,或者在空闲任务中让单片机休眠或者进入低功耗模式。

三、空闲任务的实现

  1. 定义空闲任务的任务栈
  2. 定义空闲任务的 TCB
  3. 空闲任务的创建

注意,空闲任务的任务栈和 TCB 变量我们都在 main.c 中声明为全局变量,但是同时,我们想在开启任务调度器的时候自动创建一个空闲任务,而 RTOS 的开发人员不用显式地去创建空闲任务,所以我们把空闲任务的创建集成在 void vTaskStartScheduler( void ) 这个函数中。这样,我们在启动调度器的同时就会自动创建一个空闲任务。代码如下:

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 )
    {
        /* 调度器启动成功,则不会返回,即不会来到这里 */
    }
}

上面这段代码调用了 xTaskCreateStatic() 这个函数进行空闲任务的创建,但是这个函数需要传入空闲任务的任务栈和 TCB 变量,而我们把这些变量定义在了 main.c 中,所以需要使用 vApplicationGetIdleTaskMemory() 这个函数来使 vTaskStartScheduler() 函数中的任务指针等等变量指向定义在 main.c 中的任务栈和 TCB,然后再把这些任务指针等传入 xTaskCreateStatic() 中。vApplicationGetIdleTaskMemory() 的具体代码如下:

void vApplicationGetIdleTaskMemory( TCB_t **ppxIdleTaskTCBBuffer, 
                                    StackType_t **ppxIdleTaskStackBuffer, 
                                    uint32_t *pulIdleTaskStackSize )
{
		*ppxIdleTaskTCBBuffer=&IdleTaskTCB;
		*ppxIdleTaskStackBuffer=IdleTaskStack; 
		*pulIdleTaskStackSize=configMINIMAL_STACK_SIZE;
}

四、任务中的阻塞延时怎么实现

具体想法如下:

  1. 为 TCB 添加记录延时时间的参数
  2. 在任务中调用阻塞延时函数时,会给 TCB 记录延时时间的参数进行赋值,然后调用任务切换函数
  3. 调用任务切换函数会产生 PendSV 中断,在 PendSV中断服务函数中会调用上下文切换函数 vTaskSwitchContext()
  4. 在上下文切换函数中,我们更新当前执行任务的指针。现在我们的思想是,如果当前任务是空闲任务,那么查看其他任务的延时是否结束,如果没有结束就继续执行空闲任务;如果当前执行的不是空闲任务,那么检查一下其他任务是否在延时中,如果不在延时中,就不忘初心进行任务切换,如果在延时中,就判断现在这个任务是否要延时,如果要延时就切换到空闲任务,否则就不进行任何切换。
  5. 上面检查任务是否在延时状态都是通过检查 TCB 的延时参数是否为 0 来实现的,我们使用 SysTick 中断来对 TCB 的延时参数进行定时修改
  6. 在每次 SysTick 中断触发时,我们更新一下系统时基计数器(以后有用),然后扫描一下就绪列表中所有 TCB 的延时参数,不为 0 就减 1,最后尝试任务切换

1. 为 TCB 添加记录延时时间的参数

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;

2. 阻塞延时函数 vTaskDelay()

给 TCB 记录延时时间的参数进行赋值,然后调用任务切换函数。

void vTaskDelay( const TickType_t xTicksToDelay )
{
    TCB_t *pxTCB = NULL;
    
    /* 获取当前任务的TCB */
    pxTCB = pxCurrentTCB;
    
    /* 设置延时时间 */
    pxTCB->xTicksToDelay = xTicksToDelay;
    
    /* 任务切换 */
    taskYIELD();
}

3. 上下文切换函数 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;		/* 返回,不进行切换,因为两个线程都处于延时中 */
			}
		}
	}
}

4. SysTick 中断对 TCB 的延时参数进行定时修改

/*
*************************************************************************
*                             SysTick中断服务函数
*************************************************************************
*/
void xPortSysTickHandler( void )
{
	/* 关中断 */
    vPortRaiseBASEPRI();
    
    /* 更新系统时基 */
    xTaskIncrementTick();

	/* 开中断 */
    vPortClearBASEPRIFromISR();
}

每次 SysTick 中断触发时,我们更新一下系统时基计数器(以后有用),然后扫描一下就绪列表中所有 TCB 的延时参数,不为 0 就减 1,最后尝试任务切换:

void xTaskIncrementTick( void )
{
    TCB_t *pxTCB = NULL;
    BaseType_t i = 0;
    
    /* 更新系统时基计数器xTickCount,xTickCount是一个在port.c中定义的全局变量 */
    const TickType_t xConstTickCount = xTickCount + 1;
    xTickCount = xConstTickCount;

    
    /* 扫描就绪列表中所有线程的xTicksToDelay,如果不为0,则减1 */
	for(i=0; i<configMAX_PRIORITIES; i++)
	{
        pxTCB = ( TCB_t * ) listGET_OWNER_OF_HEAD_ENTRY( ( &pxReadyTasksLists[i] ) );
		if(pxTCB->xTicksToDelay > 0)
		{
			pxTCB->xTicksToDelay --;
		}
	}
    
    /* 任务切换 */
    portYIELD();
}

关于上面这段代码,有一段写得很奇怪:

    /* 更新系统时基计数器xTickCount,xTickCount是一个在port.c中定义的全局变量 */
    const TickType_t xConstTickCount = xTickCount + 1;
    xTickCount = xConstTickCount;

笔者刚开始看到的时候想问:直接递增xTickCount不行吗,为什么要写成
const TickType_t xConstTickCount = xTickCount + 1;
xTickCount = xConstTickCount;
这样不是画蛇添足吗?使代码更复杂。

其实不然,在任务调度器中,xTickCount 变量用于记录系统的时基计数器。它的目的是跟踪系统运行的时间,并且根据需要递增。

直接递增 xTickCount 可能会导致并发问题。在多线程或多任务的情况下,如果有多个任务同时尝试递增 xTickCount,并且中间存在竞争条件,可能会导致计数不准确或不一致。

为了避免这种并发问题,代码中将递增操作分解为两个步骤:

首先,通过 const TickType_t xConstTickCount = xTickCount + 1; 将 xTickCount 的值复制到一个中间变量 xConstTickCount 中,并递增这个中间变量。

然后,将中间变量 xConstTickCount 的值赋回给 xTickCount,完成递增操作。

这样做的好处是,无论何时进行递增操作,代码都使用了一个稳定的中间值 xConstTickCount 来执行计算和更新。这确保了计数器 xTickCount 在整个递增过程中保持一致,并且不会受到其他任务的干扰。这样可以避免并发问题,提高代码的可靠性和正确性。

5. 最后是 SysTick 的相关初始化代码

在调度器启动函数 xPortStartScheduler() 函数中调用 vPortSetupTimerInterrupt():

/*
*************************************************************************
*                              调度器启动函数
*************************************************************************
*/


BaseType_t xPortStartScheduler( void )
{
	/*
	PendSV是一个用于低优先级任务切换的软件中断。
	通过触发PendSV中断,可以请求处理器在合适的时
	间切换到更高优先级的任务。PendSV中断具有最低
	的中断优先级,因此可以在其他中断处理完成后立
	即执行。*/
    /* 配置PendSV 和 SysTick 的中断优先级为最低 */
	portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
	portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;
	
	//初始化SysTick中断
	vPortSetupTimerInterrupt();

	/* 启动第一个任务,不再返回 */
	prvStartFirstTask();

	/* 不应该运行到这里 */
	return 0;
}

初始化 SysTick 的函数 vPortSetupTimerInterrupt():

/*
*************************************************************************
*                             初始化SysTick
*************************************************************************
*/
void vPortSetupTimerInterrupt( void )
{
     /* 设置重装载寄存器的值 */
    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 ); 
}

这里解释一下重装载寄存器的值怎么设置。计时器实际上是一个计数器,当接收到设定数量的脉冲后进行一次中断,而这个设定的数量就是重装载寄存器的值。

我们把计时器接入到 CPU 晶振后,由于晶振每隔一段固定时间发出一个脉冲信号,此时计时器就将重装载寄存器的值减 1,当重装载寄存器的值减到 0 后,就触发一次中断,由此完成了对晶振的高频率信号的分频。

注意,重装载寄存器的值是从 0 开始减的,所以设置时要减 1。

可以看到,我们使用 configSYSTICK_CLOCK_HZ / configTICK_RATE_HZ ) - 1UL 进行设置,configSYSTICK_CLOCK_HZ 实际上就是 CPU 的晶振频率,而 configTICK_RATE_HZ 就是我们设置 SysTick 的中断频率。

其中的宏定义为:

#define configCPU_CLOCK_HZ			( ( unsigned long ) 25000000 )	
#define configTICK_RATE_HZ			( ( TickType_t ) 100 )


/* SysTick 配置寄存器 */
#define portNVIC_SYSTICK_CTRL_REG			( * ( ( volatile uint32_t * ) 0xe000e010 ) )
#define portNVIC_SYSTICK_LOAD_REG			( * ( ( volatile uint32_t * ) 0xe000e014 ) )

#ifndef configSYSTICK_CLOCK_HZ
	#define configSYSTICK_CLOCK_HZ configCPU_CLOCK_HZ
	/* 确保SysTick的时钟与内核时钟一致 */
	#define portNVIC_SYSTICK_CLK_BIT	( 1UL << 2UL )
#else
	#define portNVIC_SYSTICK_CLK_BIT	( 0 )
#endif

#define portNVIC_SYSTICK_INT_BIT			( 1UL << 1UL )
#define portNVIC_SYSTICK_ENABLE_BIT			( 1UL << 0UL )

后记

如果您觉得本文写得不错,可以点个赞激励一下作者!
如果您发现本文的问题,欢迎在评论区或者私信共同探讨!
共勉!

你可能感兴趣的:(RTOS,学习,RTOS)