在学习FreeRTOS过程中,结合韦东山-FreeRTOS手册和视频、野火-FreeRTOS内核实现与应用开发、及网上查找的其他资源,整理了该篇文章。如有内容理解不正确之处,欢迎大家指出,共同进步。
1. 任务的调度机制(核心是链表)
- 使用链表来管理任务
- 谁进行调度?
- TICK中断!每隔固定时间,会产生的一个定时器中断
- 第一个运行的任务是谁?
- 最高优先级的ready list里最后一个创建的任务
1.1 重要概念
这些知识在前面都提到过了,这里总结一下。
- 正在运行的任务,被称为"正在使用处理器",它处于运行状态。在单处理系统中,任何时间里只能有一个任务处于运行状态。
- 非运行状态的任务,它处于这3种状态之一:阻塞(Blocked)、挂起(Suspended)、就绪(Ready)。
- 就绪态的任务,可以被调度器挑选出来切换为运行状态,调度器永远都是挑选最高优先级的就绪态任务并让它进入运行状态。
- 阻塞状态的任务,它在等待"事件",当事件发生时任务就会进入就绪状态。事件分为两类:时间相关的事件、同步事件。
- 所谓时间相关的事件,就是设置超时时间:在指定时间内阻塞,时间到了就进入就绪状态。使用时间相关的事件,可以实现周期性的功能、可以实现超时功能。
- 同步事件就是:某个任务在等待某些信息,别的任务或者中断服务程序会给它发送信息。怎么"发送信息"?方法很多,有:任务通知(task notification)、队列(queue)、事件组(event group)、信号量(semaphoe)、互斥量(mutex)等。这些方法用来发送同步信息,比如表示某个外设得到了数据。
1.2 配置调度算法
所谓调度算法,就是怎么确定哪个就绪态的任务可以切换为运行状态。
通过配置文件FreeRTOSConfig.h的两个配置项来配置调度算法:configUSE_PREEMPTION、configUSE_TIME_SLICING。
还有第三个配置项:configUSE_TICKLESS_IDLE,它是一个高级选项,用于关闭Tick中断来实现省电,后续单独讲解。现在我们假设configUSE_TICKLESS_IDLE被设为0,先不使用这个功能。
调度算法的行为主要体现在两方面:高优先级的任务先运行、同优先级的就绪态任务如何被选中。调度算法要确保同优先级的就绪态任务,能"轮流"运行,策略是"轮转调度"(Round Robin Scheduling)。轮转调度并不保证任务的运行时间是公平分配的,我们还可以细化时间的分配方法。 从3个角度统一理解多种调度算法:
- 可否抢占?高优先级的任务能否优先执行(配置项: configUSE_PREEMPTION)
- 可以:被称作"可抢占调度"(Pre-emptive),高优先级的就绪任务马上执行,下面再细化。
- 不可以:不能抢就只能协商了,被称作"合作调度模式"(Co-operative Scheduling)
- 当前任务执行时,更高优先级的任务就绪了也不能马上运行,只能等待当前任务主动让出CPU资源。
- 其他同优先级的任务也只能等待:更高优先级的任务都不能抢占,平级的更应该老实点
- 可抢占的前提下,同优先级的任务是否轮流执行(配置项:configUSE_TIME_SLICING)
- 轮流执行:被称为"时间片轮转"(Time Slicing),同优先级的任务轮流执行,你执行一个时间片、我再执行一个时间片
- 不轮流执行:英文为"without Time Slicing",当前任务会一直执行,直到主动放弃、或者被高优先级任务抢占
- 在"可抢占"+"时间片轮转"的前提下,进一步细化:空闲任务是否让步于用户任务(配置项:configIDLE_SHOULD_YIELD)
- 空闲任务低人一等,每执行一次循环,就看看是否主动让位给用户任务
- 空闲任务跟用户任务一样,大家轮流执行,没有谁更特殊
列表如下:
调度——可抢占+时间片轮转+空闲任务让步
- 最高优先级的任务先运行;相同优先级的任务轮流运行
- 高优先级的任务未执行完,低优先级的任务无法运行
- 一旦高优先级任务就绪,马上运行
- 最高优先级的任务有多个,它们轮流运行
1.3 调度过程
1.3.1 任务状态链表
- 就绪链表:
pxReadyTasksLists[configMAX_PRIORITIES]
,- 其中
configMAX_PRIORITIES = 56
,就绪链表有56个优先级; - 空闲任务优先级为 0,放入
pxReadyTasksLists[0]
链表中; osPriorityNormal=24
;当任务的优先级设为osPriorityNormal
时,则该任务在pxReadyTasksLists[24]
链表中;- 当发生Tick中断时,会发生调度:从上到下遍历
pxReadyTasksLists
链表,找到第一个非空的List,把pxCurrentTCB
指向下一个任务,启动它;
- 其中
- 阻塞链表:
pxDelayedTaskList
- 当调用
vTaskDelay(xTicksToDelay);
后:- 任务从就绪链表
pxReadyTasksLists
删除,放入阻塞链表pxDelayedTaskList
- 触发调度:从上到下遍历
pxReadyTasksLists
链表,找到第一个非空的List。链表中会有一个记录项Index,Index会指向上一次运行的任务。在调度时,会取出下一个任务(上一次运行的任务的下一个)来运行。
- 任务从就绪链表
- 当
xTicksToDelay
时间到后:- 在每个Tick中断中,都会判断
pxDelayedTaskList
中的任务xTicksToDelay
是否到了;到的话,放入pxReadyTasksLists
; - 调度:从上到下遍历
pxReadyTasksLists
链表,找到第一个非空的List,把pxCurrentTCB
指向下一个任务,启动它;
- 在每个Tick中断中,都会判断
- 当调用
- 挂起链表:
xSuspendedTaskList
- 当调用
vTaskSuspend(TaskHandle);
后:- 会使
TaskHandle
任务从就绪链表或阻塞链表中删除,放入挂起链表xSuspendedTaskList
; - 处于挂起链表中的任务,并不能通过Tick中断将其唤醒;需要调用
vTaskResume(TaskHandle);
- 当调用
vTaskResume(TaskHandle);
后,任务就从挂起链表中删除,重新放入就绪链表pxReadyTasksLists
- 会使
- 当调用
1.3.2 调度过程
- 创建的任务如下:
- 所处链表:
1.3.3 调度流程
2. SVC和PendSV、SysTick介绍
在Contex-M3架构中,FreeRTOS为了任务启动和任务切换使用了三个异常:SVC、PendSV和SysTick:
- SVC(系统服务调用,简称系统调用)
- 用于任务启动,有些操作系统不允许应用程序直接访问硬件,而是通过提供一些系统服务函数,用户程序使用 SVC发出对系统服务函数的呼叫请求,以这种方法调用它们来间接访问硬件,它就会产生一个 SVC 异常。SVC产生的中断必须立即得到响应,否则将触发硬Fault。
- 触发SVC异常,会立即执行SVC异常代码。系统在启动调度器函数
vTaskStartSchedulerp</font>
最后运行到prvStartFirstTask</font>
中会调用SVC并启动第一个任务。
- PendSV(可挂起系统调用)
- 用于完成任务切换,它是可以像普通的中断一样被挂起的,它的最大特性是如果当前有优先级比它高的中断在运行,PendSV 会延迟执行,直到高优先级中断执行完毕,这样子产生的 PendSV 中断就不会打断其他中断的运行。
- 这里将 PendSV 和 SysTick 异常优先级设置为最低,这样任务切换不会打断某个中断服务程序,中断服务程序也不会被延迟,这样简化了设计,有利于系统稳定。有人可能会问,那 SysTick 的优先级配置为最低,那延迟的话系统时间会不会有偏差?答案是不会的,因为 SysTick 只是当次响应中断被延迟了,而 SysTick 是硬件定时器,它一直在计时,这一次的溢出产生中断与下一次的溢出产生中断的时间间隔是一样的,至于系统是否响应还是延迟响应,这个与SysTick 无关,它照样在计时。
- SysTick
- 用于产生系统节拍时钟,提供一个时间片。如果多个任务共享同一个优先级,则每次 SysTick 中断,下一个任务将获得一个时间片。
- 一般默认时间片时钟为1ms,进入Systick中断后,内核会进入处理模式进行处理,在Systick中断处理中,系统会在 ReadList 就绪链表从高优先级到低优先找需要执行的任务,进行调度,如果有任务的状态发生了变化,改变了状态链表,就会产生一个pendSV异常,进入pendSV异常,通过改变进程栈指针(PSP)切换到不同的任务。
- 对于相同优先级的任务,每隔一个Systick,运行过的任务被自动排放至该优先级链表的尾部(时间片调度)
- 用户也可以在线程模式下主动触发PendSV,进行任务切换。
- 在FreeRTOS中SVC只使用了一次(M0中没有使用),就是第一次。
- FreeRTOS进入临界区是通过配置BASEPRI寄存器来进行的。
3. Systick
3.1 寄存器设置
3.1.1 Systick定时器寄存器
Systick定时器中存在4个寄存器:
当前值寄存器(SysTick->VAL)中有一个24位向下计数器,根据处理器时钟或一个参考时钟信号(在ARMCortex-M3或Cortex-M4技术参考手册中也被称作STCLK)来减小计数。参考时钟信号取决于微控制器的实际设计,有些情况下,它可能会不存在。由于要检测上升沿,参考时钟至少得比处理器时钟慢两倍。
在设置CTRL(控制和状态寄存器)的第 0 位使能该计数器后,VAL(当前值寄存器)在每个处理器时钟周期或参考时钟的上升沿都会减小。若计数减至0,它会从LOAD(重装载寄存器)中加载数值并继续运行。
另外一个寄存器为SysTick校准值寄存器。它为软件提供了校准信息。由于CMSIS-Core提供了一个名为SystemCoreClock的软件变量(CMSISl.2及之后版本可用,CMSIS1.1或之前版本则使用变量SystemFrequency),因此它就未使用SysTick校准值寄存器。
系统初始化函数SystemInit()函数设置了该变量,而且每次系统时钟配置改变时都要对其进行更新。
这种软件手段比利用SysTick校准值寄存器的硬件方式更灵活。
3.1.2 PendSV挂起设置
3.2 Systick运行流程
在Cortex-M系列中 systick是作为FreeRTOS 的心跳时钟,是调度器的核心。
系统是在Systick中进行上下文切换。
- 执行流程:
3.2.1 Systick初始化vPortSetupTimerInterrupt
systick的初始化在port.c
中, vPortSetupTimerInterrupt
函数:
/*
* Setup the SysTick timer to generate the tick interrupts at the required
* frequency.
*/
#if( configOVERRIDE_DEFAULT_TICK_CONFIGURATION == 0 )
void vPortSetupTimerInterrupt( void )
{
/* Calculate the constants required to configure the tick interrupt. */
#if( configUSE_TICKLESS_IDLE == 1 )
{
ulTimerCountsForOneTick = ( configSYSTICK_CLOCK_HZ / configTICK_RATE_HZ );
xMaximumPossibleSuppressedTicks = portMAX_24_BIT_NUMBER / ulTimerCountsForOneTick;
ulStoppedTimerCompensation = portMISSED_COUNTS_FACTOR / ( configCPU_CLOCK_HZ / configSYSTICK_CLOCK_HZ );
}
#endif /* configUSE_TICKLESS_IDLE */
/* Stop and clear the SysTick. 清0,保证上电后的准确性*/
portNVIC_SYSTICK_CTRL_REG = 0UL;
portNVIC_SYSTICK_CURRENT_VALUE_REG = 0UL;
/* Configure SysTick to interrupt at the requested rate.
portNVIC_SYSTICK_LOAD_REG systick装载值
portNVIC_SYSTICK_CTRL_REG systick控制寄存器
配置系统时钟源,开启中断,使能
*/
portNVIC_SYSTICK_LOAD_REG = ( configSYSTICK_CLOCK_HZ / configTICK_RATE_HZ ) - 1UL;
portNVIC_SYSTICK_CTRL_REG = ( portNVIC_SYSTICK_CLK_BIT | portNVIC_SYSTICK_INT_BIT | portNVIC_SYSTICK_ENABLE_BIT );
}
#endif /* configOVERRIDE_DEFAULT_TICK_CONFIGURATION */
3.2.2 Systick中断服务函数xPortSysTickHandler
每一节拍进入一次Systick 中断, 如果调度器返回true,触发pendSV异常:
void xPortSysTickHandler( void )
{
vPortRaiseBASEPRI();// 屏蔽所有中断
{
/* Increment the RTOS tick.
对tick的计数值进行自加,检查有没有处于就绪态的最高优先级的任务,
如果有,返回pdTrue,表示需要进行任务切换,而并非马上进行任务切换;
只是向中断状态寄存器bit28位写入1,只是将PendSV挂起,
假如没有比PendSV更高优先级的中断,
它才会进入PendSV中断服务函数进行任务切换。
*/
if( xTaskIncrementTick() != pdFALSE )
{
/* A context switch is required. Context switching is performed in
the PendSV interrupt. Pend the PendSV interrupt.
往中断控制及状态寄存器 ICSR(地址:0xE000_ED04)的 bit28
写 1 挂起一次 PendSV 中断
触发 pendSV
*/
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
}
}
vPortClearBASEPRIFromISR();// 解除屏蔽所有中断,执行pendSV
}
3.2.3 Systick任务调度xTaskIncrementTick
Systick中断中,调用xTaskIncrementTick
任务调度如下:
BaseType_t xTaskIncrementTick( void )
{
TCB_t * pxTCB;
TickType_t xItemValue;
BaseType_t xSwitchRequired = pdFALSE;//返回值,表示是否进行上下文切换
/* Called by the portable layer each time a tick interrupt occurs.
Increments the tick then checks to see if the new tick value will cause any
tasks to be unblocked. */
traceTASK_INCREMENT_TICK( xTickCount );
/*
uxSchedulerSuspended 表示内核调度器是否挂起
pdFALSE 表示内核没有挂起
*/
if( uxSchedulerSuspended == ( UBaseType_t ) pdFALSE )
{
/* Minor optimisation. The tick count cannot change in this
block. tick计数增加1*/
const TickType_t xConstTickCount = xTickCount + ( TickType_t ) 1;
/* Increment the RTOS tick, switching the delayed and overflowed
delayed lists if it wraps to 0. */
xTickCount = xConstTickCount;
/*
判断tick是否溢出越界
*/
if( xConstTickCount == ( TickType_t ) 0U ) /*lint !e774 'if' does not always evaluate to false as it is looking for an overflow. */
{
taskSWITCH_DELAYED_LISTS();//如果溢出,要更新延时列表
}
/*
当前节拍大于时间片的锁定时间
说明有任务需要进行调度了,时间片用完了*/
if( xConstTickCount >= xNextTaskUnblockTime )
{
for( ;; )
{
if( listLIST_IS_EMPTY( pxDelayedTaskList ) != pdFALSE )
{
/* delayList为空,如果没有任务等待,把时间片赋值为最大值,不再调度*/
xNextTaskUnblockTime = portMAX_DELAY; /*lint !e961 MISRA exception as the casts are only redundant for some ports. */
break;
}
else
{
/* delayList不空,从delayList中获取第一个TCB,
将其从delayList中删除*/
pxTCB = ( TCB_t * ) listGET_OWNER_OF_HEAD_ENTRY( pxDelayedTaskList );
xItemValue = listGET_LIST_ITEM_VALUE( &( pxTCB->xStateListItem ) );
/* 直到将延时列表中所有延时到期的任务移除才跳出 for 循环 */
if( xConstTickCount < xItemValue )
{
/* 再次判断delay的时间是否到达,没有到达,将时间片更新为当前系统的时间片*/
xNextTaskUnblockTime = xItemValue;
break;
}
/* 将任务从delayList即阻塞态链表中移除*/
( void ) uxListRemove( &( pxTCB->xStateListItem ) );
/* 如果是在等待事件,将其从事件链表中移除*/
if( listLIST_ITEM_CONTAINER( &( pxTCB->xEventListItem ) ) != NULL )
{
( void ) uxListRemove( &( pxTCB->xEventListItem ) );
}
/* 将任务放入就绪链表*/
prvAddTaskToReadyList( pxTCB );
/* 如果不可抢占,则不会立即产生上下文切换*/
#if ( configUSE_PREEMPTION == 1 )
{
/* 可抢占,则仅当被阻塞的任务的优先级>=当前任务的优先级时,
才能上下文切换*/
if( pxTCB->uxPriority >= pxCurrentTCB->uxPriority )
{
xSwitchRequired = pdTRUE; // 发生上下文切换
}
}
#endif /* configUSE_PREEMPTION */
}
}
}
/* 可抢占的前提下,同时configUSE_TIME_SLICING == 1,则同优先级的任务轮流执行;
否则:当前任务一直执行,直到主动放弃、或者被高优先级任务抢占
*/
#if ( ( configUSE_PREEMPTION == 1 ) && ( configUSE_TIME_SLICING == 1 ) )
{
if( listCURRENT_LIST_LENGTH( &( pxReadyTasksLists[ pxCurrentTCB->uxPriority ] ) ) > ( UBaseType_t ) 1 )
{
xSwitchRequired = pdTRUE;// 发生上下文切换
}
}
#endif /* ( ( configUSE_PREEMPTION == 1 ) && ( configUSE_TIME_SLICING == 1 ) ) */
/*
configUSE_TICK_HOOK 设置为1,使用时间片钩子,可以实现定时器功能;
为0,忽略时间片钩子
*/
#if ( configUSE_TICK_HOOK == 1 )
{
/* Guard against the tick hook being called when the pended tick
count is being unwound (when the scheduler is being unlocked).
*/
if( uxPendedTicks == ( UBaseType_t ) 0U )
{
vApplicationTickHook();
}
else
{
mtCOVERAGE_TEST_MARKER();
}
}
#endif /* configUSE_TICK_HOOK */
}
else//内核调度器挂起了
{
++uxPendedTicks;//挂起的tick+1
/* The tick hook gets called at regular intervals, even if the
scheduler is locked. */
#if ( configUSE_TICK_HOOK == 1 )
{
vApplicationTickHook();
}
#endif
}
// 抢占式,要开启调度
#if ( configUSE_PREEMPTION == 1 )
{
if( xYieldPending != pdFALSE )
{
xSwitchRequired = pdTRUE;
}
else
{
mtCOVERAGE_TEST_MARKER();
}
}
#endif /* configUSE_PREEMPTION */
return xSwitchRequired;//返回调度器状态
}
3.3 Systick优先级
在上面图示中,可以看到优先级SysTick优先级最高!那么这和我们常听到的SysTick优先级需要设置为最低优先级怎么相互冲突呢?初学者往往在这个问题上感到困惑。
首先要明白:SysTick是中断,中断优先级和任务优先级没有任何关系,不管中断优先级是多少,中断的优先级永远高于任何线程任务的优先级
那么在上图中的线程,不管什么线程,SysTick中断来了肯定是需要去执行SysTick中断事件的。
上图中还有一个IRQ,比SysTick优先级低,这也是可能的,但是实际上我们应用过程中,一般都把SysTick优先级设置为最低,因为不想让SysTick中断打断用户的IRQ中断。
4. PendSV
PendSV异常用于任务切换。
为了保证操作系统的实时性,除了使用Systick的时间片调度,还得加入pendSV异常加入抢占式调度。
PendSV(可挂起的系统调用),异常编号为14,可编程。可以写入中断控制和状态寄存器(ICSR)设置挂起位以触发 PendSV异常。它是不精确的。因此,它的挂起状态可以在更高优先级异常处理内设置,且会在高优先级处理完成后执行。
为什么需要PendSV异常?
如果中断请求在Systick异常前产生,则Systick可能会抢占IRQ处理(图中的IRQ优先级小于Systick)。这样执行上下文切换会导致IRQ延时处理,这种行为在任何一种实时操作系统中都是不能容忍的,在CortexM3中如果OS在活跃时尝试切入线程模式,将触发Fault异常。
为了解决上面的问题,使用了 PendSV异常。 PendSV异常会自动延迟上下文切换的请求,直到其他的eISR都完成了处理后才放行。为实现这个机制,需要把 PendSV编程为最低优先级的异常。
在FreeRTOS中,每一次进入Systick中断,系统都会检测是否有新的进入就绪态的任务需要运行,如果有,则悬挂PendSV异常,来缓期执行上下文切换。
在Systick中会挂起一个PendSV异常用于上下文切换,每产生一个Systick,系统检测到任务链表变化都会触发一个PendSV,如下图:
- 任务A呼叫SVC来请求任务切换(例如,等待某些工作完成)
- OS接收到请求,做好上下文切换的准备,并且pend一个PendSV异常。
- 当CPU退出SVC后,它立即进入PendSV,从而执行上下文切换。
- 当PendSV执行完毕后,将返回到任务B,同时进入线程模式。
- 发生了一个中断,并且中断服务程序开始执行
- 在执行过程中,发生sysTick异常,并且抢占了该ISR
- OS执行必要的操作,然后pend起PendSV异常以作好上下文切换的准备。
- 当sysTick退出后,回到先前被抢占的ISR中,ISR继续执行
- ISR执行完毕并退出后,PendSV服务例程开始执行,并且在里面执行上下文切换
- 当PendSV执行完毕后,回到任务A,同时系统再次进入线程模式。
5. 实现调度器
调度器是操作系统的核心,其主要功能就是实现任务的切换,即从就绪列表里面找到优先级最高的任务,然后去执行该任务。
以下内容大部分参考“普中FreeRTOS开发攻略”和“野火FreeRTOS内核实现与应用”。
5.1 启动调度器
通过main.c
中的main
函数调用了osKernelStart();
5.1.1 osKernelStart (void)启动内核
osStatus_t osKernelStart (void) {
osStatus_t stat;
if (IS_IRQ()) {
stat = osErrorISR;
}
else {
if (KernelState == osKernelReady) {
KernelState = osKernelRunning;
vTaskStartScheduler();
stat = osOK;
} else {
stat = osError;
}
}
return (stat);
}
5.1.2 vTaskStartScheduler();任务调度器
创建空闲任务,启动任务调度器vTaskStartScheduler:
void vTaskStartScheduler( void )
{
BaseType_t xReturn;
/* The Idle task is being created using dynamically allocated RAM. */
(1) xReturn = xTaskCreate( prvIdleTask,
configIDLE_TASK_NAME,
configMINIMAL_STACK_SIZE,
( void * ) NULL,
( tskIDLE_PRIORITY | portPRIVILEGE_BIT ),
&xIdleTaskHandle );
/* 如果使能了软件定时器,还会创建一个软件定时器 */
#if ( configUSE_TIMERS == 1 )
{
if( xReturn == pdPASS )
{
(2) xReturn = xTimerCreateTimerTask();
}
else
{
mtCOVERAGE_TEST_MARKER();
}
}
#endif /* configUSE_TIMERS */
(3) portDISABLE_INTERRUPTS(); // 关中断
/* 下一个任务锁定时间赋值为最大不让时间片进行调度 */
xNextTaskUnblockTime = portMAX_DELAY;
(4) xSchedulerRunning = pdTRUE; //调度器的运行状态置位,标记开始运行了
xTickCount = ( TickType_t ) 0U; //用于记录系统的时间,在SysTick中断服务函数中进行自加。
(5) portCONFIGURE_TIMER_FOR_RUN_TIME_STATS();
/* 启动调度器*/
(6) if( xPortStartScheduler() != pdFALSE )
{
/* 调度器启动成功,则不会返回,即不会来到这里 */
}
( void ) xIdleTaskHandle;
}
- 代码(1):创建空闲任务,如果使用静态内存的话使用函数
xTaskCreateStatic()
来创建空闲任务,优先级为tskIDLE_PRIORITY
,宏tskIDLE_PRIORITY
为0
,也就是说空闲任务的优先级为最低。 - 代码(2):如果使用软件定时器的话还需要通过函数
xTimerCreateTimerTask()
来创建定时器服务任务。定时器服务任务的具体创建过程是在函数xTimerCreateTimerTask()
中完成的。 - 代码(3):关闭中断,在 SVC 中断服务函数
vPortSVCHandler()
中会打开中断。 - 代码(4):变量
xSchedulerRunning
设置为 pdTRUE,表示调度器开始运行。 - 代码(5):当宏
configGENERATE_RUN_TIME_STATS
为 1 的时候说明使能时间统计功能。 - 代码(6):调用函数
xPortStartScheduler()
来初始化跟调度器启动有关的硬件,比如滴答定时器、FPU 单元和 PendSV 中断等等。该函数在 port.c 中实现。
5.1.3 xPortStartScheduler启动调度器
5.1.3.1 xPortStartScheduler代码
BaseType_t xPortStartScheduler( void )
{
/* Make PendSV and SysTick the lowest priority interrupts.
配置SysTick和PendSV的中断优先级为最低*/
(1) portNVIC_SYSPRI2_REG |= portNVIC_PENDSV_PRI;
portNVIC_SYSPRI2_REG |= portNVIC_SYSTICK_PRI;
/* Start the timer that generates the tick ISR. Interrupts are disabled
here already.
1、初始化systick----》配置为1ms的中断产生时基
2、开启systick中断
*/
(2) vPortSetupTimerInterrupt();
/* Initialise the critical nesting count ready for the first task. */
(3) uxCriticalNesting = 0;
/* 启动第一个任务,不再返回 */
(4) prvStartFirstTask();
/* 不应该运行到这里 */
return 0;
}
- 代码(1):配置
PendSV
和SysTick
的中断优先级为最低。SysTick
和PendSV
都会涉及到系统调度,系统调度的优先级要低于系统的其它硬件中断优先级,即优先响应系统中的外部硬件中断,所以SysTick
和PendSV
的中断优先级配置为最低。 - 代码(2):调用函数
vPortSetupTimerInterrupt()
来设置滴答定时器的定时周期,并且使能滴答定时器的中断。 - 代码(3):初始化临界区嵌套计数器。
- 代码(4):调用函数
prvStartFirstTask()
启动第一个任务,启动成功后,则不再返回,该函数由汇编编写,在 port.c 实现。
5.1.3.2 配置 PendSV 和 SysTick 的中断优先级为最低
5.1.4 prvStartFirstTask启动第一个任务
5.1.4.1 SCB->VTOR
prvStartFirstTask()
函数用于开始第一个任务,主要做了两个动作,一个是更新 MSP
的值,二是产生 SVC
系统调用,然后去到 SVC
的中断服务函数里面真正切换到第一个任务。
ARM Cortex-M处理器的向量表是一个特殊的内存区域,其中包含了中断和异常处理程序的入口地址。这个表在启动时被复制到RAM中,通常是从地址0x00000000
开始,但这个地址是可配置的,并且可以通过SCB_VTOR
寄存器来设置。
SCB_VTOR
寄存器的值定义了向量表相对于代码区域的偏移量。如果SCB_VTOR
的值设置为0x00000000
,那么向量表就位于代码区域的起始地址。如果设置为其他值,向量表将被放置在该偏移量处的内存地址。
如果SCB_VTOR
的值设置为0x00000000
,这通常意味着向量表位于闪存的起始地址,即系统启动时的默认位置。然而,这并不意味着MSP的地址是0x00000000
。MSP是程序的堆栈指针,它在程序运行时指向主堆栈的顶部,并且它的值会随着函数调用和局部变量的分配而变化。
5.1.4.2 prvStartFirstTask代码
__asm void prvStartFirstTask( void ) (1)
{
(2) PRESERVE8 /* 当前栈8字节对齐,如果都是32位的操作则4字节对齐 */
/* Use the NVIC offset register to locate the stack. */
/* 0xE000ED08 是 SCB_VTOR 寄存器的地址,
向量表偏移寄存器,使能向量表重定位到其他的地址 */
(3) ldr r0, =0xE000ED08
(4) ldr r0, [r0] /*r0 = 0x0000 0000 */
(5) ldr r0, [r0] /* r0 = 0x20002580 */
/* 设置主堆栈指针MSP的值 */
(6) msr msp, r0 /* 将r0的值存储到MSP,MSP是主堆栈的栈顶指针*/
/* 使能全局中断*/
(7) cpsie i // 开中断
cpsie f // 开异常
dsb
isb
/* 调用 SVC 去启动第一个任务 */
(8) svc 0 ;产生系统调用,服务号0表示SVC中断,导致SVC_Handler被调用
nop
nop
}
- 代码(1):开始第一个任务函数
- 代码(2):当前栈需按照 8 字节对齐,如果都是 32 位的操作则 4 个字节对齐即可。在 Cortex-M 中浮点运算是 8 字节的。
- 代码(3):在 Cortex-M 中,0xE000ED08 是 SCB_VTOR 寄存器的地址,里面存放的是向量表的起始地址。
- 代码(4):将 0xE000ED08 这个地址指向的内容加载到寄存器 R0,此时 R0等于 SCB_VTOR 寄存器的值,等于 0x00000000。
- 代码(5):读取 R0 中存储的地址处的数据并将其保存在 R0 寄存器,也就是读取地址0X00000000 处存储的数据,并将其保存在 R0 寄存器中(0x20002580),0x20002580是创建的任务的pxTopOfStack的地址。
- 代码(6):将 R0 的值存储到 MSP,此时 MSP 等于 0x20002568,这是主堆栈的栈顶指针。起始这一步操作有点多余,因为当系统启动的时候,执行完 Reset_Handler 的时候,向量表已经初始化完毕,MSP 的值就已经更新为向量表的起始值,即指向主堆栈的栈顶指针。
- 代码(7):使用 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,有关这三个寄存器的详细用法如下所示。
- 代码(8):产生系统调用,服务号 0 表示 SVC 中断,接下来将会执行 SVC 中断服务函数。
代码执行完毕后寄存器的值的更新结果如下:
Mode:Thread;
Stack:MSP
寄存器 | 功能 | 值 |
---|---|---|
R0 | 存放的是MSP 的值 | 0x20002580 |
SP | 一直是 MSP | 0x20002580 |
PC | 以 2 为变化单位 |
5.1.5 vPortSVCHandler()函数
SVC 中断要想被成功响应,其函数名必须与向量表注册的名称一致,在启动文件的向量表中,SVC 的中断服务函数注册的名称是 SVC_Handler
,所以 SVC 中断服务函数的名称我们应该写成 SVC_Handler
,但是在 FreeRTOS 中,官方版本写的是 vPortSVCHandler()
,为了能够顺利的响应 SVC 中断,我们有两个选择,改中断向量表中 SVC 的注册的函数名称或者改 FreeRTOS 中 SVC 的中断服务名称。这里,我们采取第二种方法,即在 FreeRTOSConfig.h 中添加添加宏定义的方法来修改,具体代码如下,顺便把 PendSV 的中断服务函数名也改成与向量表的一致。
#define xPortPendSVHandler PendSV_Handler
#define xPortSysTickHandler SysTick_Handler
#define vPortSVCHandler SVC_Handle
vPortSVCHandler()函数开始真正启动第一个任务,不再返回,实现具体代码如下所示。
当进入该代码时:
R0 | 之前保存的MSP值 0x20002580 |
---|---|
SP(MSP) | 0x20002560 |
LR | 0xFFFFFFF9 |
Mode | Handler |
__asm void vPortSVCHandler( void )
{
PRESERVE8 /*8字节对齐*/
(1) ldr r3, =pxCurrentTCB /* r3=&pxCurrentTCB,指向当前任务的TCB指针所在地址 */
(2) ldr r1, [r3] /* r1=*r3=pxCurrentTCB,将r1指向当前任务的TCB*/
(3) ldr r0, [r1] /* r0=*r1=pxTopOfStack,即让r0指向当前任务栈顶*/
(4) ldmia r0!, {r4-r11}
(5) msr psp, r0 /* psp = pxTopOfStack */
isb /*指令同步隔离,确保之前的指令都已执行完毕*/
(6) mov r0, #0 /* r0 清0*/
/* basepri = 0,即打开所有中断,basepri是一个中断屏蔽寄存器,>=此寄存器值的中断都将被屏蔽*/
(7) msr basepri, r0
(8) orr r14, #0xd /* 按位或,R14 |=0xd */
(9) bx r14
}
- 代码(1):pxCurrentTCB 是一个在 task.c 中定义的全局指针,用于指向当前正在运行或者即将要运行的任务的任务控制块。加载 pxCurrentTCB 的地址到r3。
- 代码(2):加载 pxCurrentTCB 到 r1。
- 代码(3):加载 pxCurrentTCB 指向的任务控制块到 r0,任务控制块的第一个成员就是栈顶指针,所以此时 r0 等于栈顶指针。一个刚刚被创建还没有运行过的任务的栈空间分布具体如下图所示,即 r0 等于图中的 pxTopOfStack(0x200014A0)。
- 代码(4):以 r0 为基地址,将栈中向上增长的 8 个字的内容加载到 CPU 寄存器 r4~r11,同时 r0 也会跟着自增。0x200014C0(只更新了r4~r9)
- 代码(5):将新的栈顶指针 r0 更新到 psp,任务执行的时候使用的堆栈指针是 psp。
- 代码(6):将寄存器 r0 清 0。
- 代码(7):设置 basepri 寄存器的值为 0,即打开所有中断。basepri 是一个中断屏蔽寄存器,大于等于此寄存器值的中断都将被屏蔽。
- 代码(8):当从 SVC 中断服务退出前,通过向 r14 寄存器最后 4 位按位或上 0x0D,使得硬件在退出时使用进程堆栈指针 PSP 完成出栈操作,并返回后进入任务模式、返回 Thumb 状态。在 SVC 中断服务里面,使用的是 MSP 堆栈指针,是处在 ARM 状态。
- 之前LR 的值为0xFFFFFFF9(返回线程模式并在返回后使用主栈)。在这之后,LR 的值为 0xFFFFFFFD(返回处理模式并在返回后使用进程栈)。
- 代码(9):异常返回,这个时候出栈使用的是 PSP 指针,自动将栈中的剩下内容加载到 CPU 寄存器: xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)同时 PSP 的值也将更新,即指向任务栈的栈顶,具体指如下图所示。之后就执行任务函数。
6. 任务切换
任务切换就是在就绪列表中寻找优先级最高的就绪任务,然后去执行该任务。由任务切换函数 taskYIELD()
实现。
6.1 源码分析
6.1.1 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 )
/* Scheduler utilities. */
#define portYIELD() \
{ \
/* Set a PendSV to request a context switch. 设置PendSV,产生上下文切换*/ \
(1) portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; \
\
/* Barriers are normally not required but do ensure the code is completely \
within the specified behaviour for the architecture. */ \
__dsb( portSY_FULL_READ_WRITE ); \
__isb( portSY_FULL_READ_WRITE ); \
}
- 代码(1):
portYIELD
的实现很简单,实际就是将PendSV
的悬起位置 1,当没有其它中断运行的时候响应PendSV
中断,去执行我们写好的PendSV
中断服务函数,在里面实现任务切换。
6.1.2 xPortPendSVHandler()函数
PendSV 中断服务函数是真正实现任务切换的地方。
__asm void xPortPendSVHandler( void )
{
extern uxCriticalNesting;
(1) extern pxCurrentTCB;
(2) extern vTaskSwitchContext;
/* 保存当前任务的现场*/
(3) PRESERVE8
/*r0=psp, 进入PendSV中断时,上个任务环境即
xPSR,PC,R14,R12,R3,R2,R1,R0这些将自动保存入任务栈,
剩下R4-R11需要手动保存,同时PSP将自动更新(在更新之前 PSP 指向任务栈的栈顶),
此时 PSP是"上文"任务的堆栈指针*/
(4) mrs r0, psp
isb
// 取出当前TCB,TCB里有栈
(5) ldr r3, =pxCurrentTCB /* r3=&pxCurrentTCB */
(6) ldr r2, [r3] /* r2=*r3=pxCurrentTCB */
(7) stmdb r0!, {r4-r11} /* 保存剩余的寄存器 */
(8) str r0, [r2] /* r0 =*r2=pxTopOfStack,更新上一个任务的栈顶*/
(9) stmdb sp!, {r3, r14} /* 入栈栈顶指针和pxCurrentTCB*/
/* 关中断,高于configMAX_SYSCALL_INTERRUPT_PRIORITY的中断都被屏蔽,*/
(10) mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
(11) msr basepri, r0
dsb
isb
(12) bl vTaskSwitchContext // 切换任务
// 开中断
(13) mov r0, #0
msr basepri, r0
/* 从MSP栈加载r3和r14,此时r3已经指向新任务pxCurrentTCB的地址值*/
(14) ldmia sp!, {r3, r14}
/* 切换新任务 */
(15) ldr r1, [r3] // 新任务的栈,r1=*r3=pxCurrentTCB
(16) ldr r0, [r1] /* r0=*r1=pxTopOfStack,即新任务的栈顶指针*/
/* 将新任务的任务栈数据加载入cpu寄存器r4-r11 */
(17) ldmia r0!, {r4-r11} /*Pop the registers and the critical nesting count. */
/* 更新psp的值,等pendSV退出时,会以psp作为基地址,将任务栈中剩下的内容自动加载到cpu寄存器*/
/* 剩下的内容包括:xPSR、PC、LR、r12、r3、r2、r1、r0*/
(18) msr psp, r0
isb
(19) bx r14 //中断结束返回
nop
}
- 代码(1):声明外部变量
pxCurrentTCB
,pxCurrentTCB
是一个在 task.c 中定义的全局指针,用于指向当前正在运行或者即将要运行的任务的任务控制块。 - 代码(2):声明外部函数
vTaskSwitchContext
,等下会用到。 - 代码(3):当前栈需按照 8 字节对齐,如果都是 32 位的操作则 4 个字节对齐即可。在 Cortex-M 中浮点运算是 8 字节的。
- 代码(4):将 PSP 的值存储到 r0。当进入
PendSVC Handler
时,上一个任务运行的环境即: xPSR,PC(任务入口地址),R14,R12,R3,R2,R1,R0(任务的形参)这些 CPU 寄存器的值会自动存储到任务的栈中,剩下的 r4~r11 需要手动保存,同时 PSP 会自动更新(在更新之前 PSP 指向任务栈的栈顶),此时 PSP 具体指向见下图。
- 代码(5):加载 pxCurrentTCB 的地址到 r3。
- 代码(6):加载 r3 指向的内容到 r2,即 r2 等于 pxCurrentTCB。
- 代码(7):以 r0 作为基址(指针先递减,再操作,STMDB 的 DB 表示 Decrease Before),将 CPU 寄存器 r4~r11 的值存储到任务栈,同时更新 r0 的值,此时r0 的指向具体见下图。
- 代码(8):将 r0 的值存储到 r2 指向的内容,r2 等于
pxCurrentTCB
。具体为将 r0 的值存储到上一个任务的栈顶指针 pxTopOfStack,具体指向如图 的 r0 指向一样。到此,上下文切换中的上文保存就完成了。 - 代码(9):将 R3 和 R14 临时压入堆栈(在整个系统中,中断使用的是主堆栈,栈指针使用的是 MSP),因为接下来要调用函数 vTaskSwitchContext,调用函数时,返回地址自动保存到 R14 中,所以一旦调用发生,R14 的值会被覆盖(PendSV 中断服务函数执行完毕后,返回的时候需要根据 R14 的值来决定返回处理器模式还是任务模式,出栈时使用的是 PSP 还是 MSP),因此需要入栈保护。R3 保存的是当前正在运行的任务(准确来说是上文,因为接下来即将要切换到新的任务)的 TCB 指针(pxCurrentTCB)地址,函数调用后 pxCurrentTCB的值会被更新,后面我们还需要通过 R3 来操作 pxCurrentTCB,但是运行函数vTaskSwitchContext 时不确定会不会使用 R3 寄存器作为中间变量,所以为了保险起见,R3 也入栈保护起来。
- 代码(10):将 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。
- 代码(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 表示Increase After),将下一个要运行的任务的任务栈的内容加载到 CPU 寄存器r4~r11。
- 代码(18):更新 psp 的值,等下异常退出时,会以 psp 作为基地址,将任务栈中剩下的内容自动加载到 CPU 寄存器。
- 代码(19):异常发生时,R14 中保存异常返回标志,包括返回后进入任务模式还是处理器模式、使用 PSP 堆栈指针还是 MSP 堆栈指针。此时的 r14 等于0xfffffffd,表示异常返回后进入任务模式,SP 以 PSP 作为堆栈指针出栈,出栈完毕后 PSP 指向任务栈的栈顶。当调用 bx r14 指令后,系统以 PSP 作为SP 指针出栈,把接下来要运行的新任务的任务栈中剩下的内容加载到 CPU 寄存器:R0(任务形参)、R1、R2、R3、R12、R14(LR)、R15(PC)和 xPSR,从而切换到新的任务。
6.1.3 PendSV上下文切换函数vTaskSwitchContext
xPortPendSVHandler
中调用的上下文切换vTaskSwitchContext
,其核心任务就是找到当前处于就绪态的最高优先级的任务:
void vTaskSwitchContext( void )
{
(1) taskSELECT_HIGHEST_PRIORITY_TASK();
}
/* 查找最高优先级的就绪任务:根据处理器架构优化后的方法 */
#define taskSELECT_HIGHEST_PRIORITY_TASK()\
{\
UBaseType_t uxTopPriority; \
portGET_HIGHEST_PRIORITY( uxTopPriority, uxTopReadyPriority );\
listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, &( pxReadyTasksLists[ uxTopPriority ] ) );\
}
7. FreeRTOS启动流程
复位函数的最后会调用 C 库函数__main。__main 函数的主要工作是初始化系统的堆和栈,最后调用 C 中 的 main 函数,从而去到 C 的世界。
从main函数开始执行,完成的内容如下:
- FreeRTOS系统上电后,完成的事情:
① 启动复位函数Reset_Handle
;
② 调用C库函数__main
,主要是为了初始化系统的堆和栈;
③ 调用C中的main
函数;
- main函数中,完成的事情:
① 对外设初始化、配置系统时钟;
②osKernelInitialize();
内核初始化,会配置系统内存大小;
③MX_FREERTOS_Init();
创建默认任务和其他任务;
④osKernelStart();
启动内核,会调用vTaskStartScheduler();
任务调度器
- 任务调度器
vTaskStartScheduler();
中,完成的事情:
①创建空闲任务xTaskCreate( prvIdleTask,..)
;
②创建软件定时器xTimerCreateTimerTask();
;
③关中断portDISABLE_INTERRUPTS();
;
④启动调度器xPortStartScheduler()
;
- 启动调度器
xPortStartScheduler();
中完成的事情:
①将PendSV和SysTick设置为最低优先级;
②SysTick初始化vPortSetupTimerInterrupt();
;
③初始化临界区嵌套计数器uxCriticalNesting = 0;
;
④启动第一个任务prvStartFirstTask();
;
- 启动第一个任务
prvStartFirstTask();
中,完成的事情:
①使能中断;
②调用SVCsvc 0
,启动第一个任务;会进入vPortSVCHandler( void )
。
在vPortSVCHandler( void )
中,