FreeRTOS任务调度器的启动流程和第一个任务被调用的全过程
前言
本文从FreeRTOS的源码入手,较为系统全面地分析了任务调度器的启动流程和第一个任务被调用的前因后果,最后采用MDK仿真的方式让大家理解任务启动的过程,也包含了很重要的调试技巧。全文包含了一些细微却重要的知识点,需要我们理解并掌握。
这一部分对于FreeRTOS整体而言至关重要,所以我尽可能用通俗易懂的方式向大家阐明。文章中大量使用截图的方式,理解起来更容易,希望可以给大家带来帮助!全文一万余字,干货满满。
任务调度器
在创建任务之后,开启任务调度器,调用的函数:
vTaskStartScheduler();
程序进入此函数是不会返回的,下面对此函数进行讲解。
创建空闲任务和软件定时器任务
创建之前先判断是动态创建,还是静态创建,这里是动态创建。另外创建空闲任务成功之后,如果使能软件定时器,则创建软件定时器任务。
关闭中断、初始化全局变量、任务调度器运行状态设置
目的:防止调度器开启之前或开启过程中,受到中断干扰。
另外中断会在运行第一个任务的时候开启。
初始化任务运行时间统计功能的时基定时器
此函数暂未实现。
位于FreeRTOSConfig.h,定义下面的宏
#define configGENERATE_RUN_TIME_STATS 0
如果上面的宏等于1,那么就需要用户实现宏函数:portCONFIGURE_TIMER_FOR_RUN_TIME_STATS()
。
二者的关系如下:
宏定义 | 作用 |
---|---|
configGENERATE_RUN_TIME_STATS | 全局开关:是否启用运行时统计功能(1=启用,0=禁用)。 |
portCONFIGURE_TIMER_FOR_RUN_TIME_STATS | 硬件依赖:配置一个高精度定时器,用于统计任务执行时间(需用户实现)。 |
调用vPortStartScheduler()
vPortStartScheduler
是 FreeRTOS 中 启动任务调度器 的核心函数,通常定义在移植层文件 port.c
中。
作用:用于完成启动任务调度器中于硬件架构相关的配置部分,比如滴答定时器的初始化,并开启第一个任务。
函数功能
- 初始化系统节拍定时器(如 SysTick)
- 配置硬件定时器,产生固定频率的中断(如 1kHz),用于任务时间片轮转和延时。
- 启动第一个任务
- 从
main()
切换到第一个任务的上下文(通常是xTaskCreate
创建的任务或软件定时器任务)。 - 设置中断优先级
- 确保 PendSV(任务切换)和 SysTick中断的优先级最低,避免阻塞其他中断。
- 永远不会返回
- 一旦调用,控制权交给 FreeRTOS 调度器,除非调度器崩溃。
实现流程
1. 检测用户在FreeRTOSConfig.h中对中断的配置是否正确。
2. 配置PendSV和SysTick中断优先级为最低。
对照《Cortex-M3权威指南》文档中的地址,以及中断优先级数值。
STM32只使用了高4位,所以只设置高四位即可。
3. 调用vPortSetupTimerInterrupt()
来配置SysTick。
通过对照手册,可以明确知道,这里是在写寄存器:重装载寄存器
和控制及状态寄存器
。下一步我们看下写入的内容。
#define configCPU_CLOCK_HZ ( SystemCoreClock )
#define configSYSTICK_CLOCK_HZ configCPU_CLOCK_HZ
#define configTICK_RATE_HZ ( ( TickType_t ) 1000 )
#define portNVIC_SYSTICK_CLK_BIT ( 1UL << 2UL )
#define portNVIC_SYSTICK_INT_BIT ( 1UL << 1UL )
#define portNVIC_SYSTICK_ENABLE_BIT ( 1UL << 0UL )
总结
首先,SysTick是从预装载值开始向下计数的定时器。上面的代码在做两件事:
1、将SysTick的重装载值设置,使得SysTick定时器的溢出中断间隔时间是1ms。
2、将SysTick定时器的模式设置:使用CPU主频为时钟源,倒数到0的时候产生中断,使能定时器。
4. 初始化临界区嵌套计数器为0。
/* Initialise the critical nesting count ready for the first task. */
uxCriticalNesting = 0;
5. 调用函数prvEnableVFP()
使能FPU(Cortex-M3没有此项)。
之后代码如下:
/* Lazy save always. */
*( portFPCCR ) |= portASPEN_AND_LSPEN_BITS;
#define portFPCCR ( ( volatile uint32_t * ) 0xe000ef34 ) /* Floating point context control register. */
#define portASPEN_AND_LSPEN_BITS ( 0x3UL << 30UL )
在地址为0xe000ef34
的寄存器,将Bit30
、Bit31
都写1。
FPU有S0-S31一共32个寄存器,然而S1-S15是自动恢复和保存的。S16-S31是需要手动恢复和保存的。此处的代码含义是:在进出异常时,自动保存和恢复FPU相关寄存器。
6. 调用函数prvStartFirstTask()
启动第一个任务。
1. 首先,创建任务的时候函数调用关系
> xTaskCreate->prvInitialiseNewTask->pxPortInitialiseStack
假设需要启动第一个任务A,就需要将任务A的寄存器值恢复到CPU寄存器,而这些寄存器值在任务创建的时候就保存在任务堆栈里面。
注:
1、在中断产生时,硬件自动将xPSR,PC(R15),LR(R14),R12,R3-R0保存。当出中断的时候,这些寄存器会自动恢复到CPU的寄存器中。而R4-R11需要手动保存和恢复。
2、进入中断后硬件会强制使用MSP指针,此时LR(R14)的值会自动被更新为特殊的EXC_RETURN。
2. 分析函数prvStartFirstTask()
内部代码
__asm void prvStartFirstTask( void )
{
PRESERVE8/*设置8字节对齐*/
/* Use the NVIC offset register to locate the stack. */
ldr r0, =0xE000ED08/*将地址0xE000ED08写进寄存器r0(这个地址是SCB->VTOR寄存器的地址)*/
ldr r0, [r0]/*将SCB->VTOR寄存器里面存储的数值取出,存放在r0(实际取出的是中断向量表的偏移地址,方便下一步操作)*/
ldr r0, [r0]/*从中断向量表的偏移地址处,取出主堆栈指针MSP的初始值*/
/* Set the msp back to the start of the stack. */
msr msp, r0/*将MSP的初始值写进MSP寄存器*/
/* Globally enable interrupts. */
cpsie i/*使能中断,清除PRIMASK 全局开中断*/
cpsie f/*使能中断,清除FAULTMASK 开 Fault 中断*/
dsb/*数据同步屏障*/
isb/*指令同步屏障*/
/* Call SVC to start the first task. */
svc 0/*触发SVC中断 启动第一个任务*/
nop
nop
}
PRESERVE8指令含义
但最上面有一条指令:PRESERVE8,目的是8字节对齐。要求当前函数的栈指针(SP)在进入和退出时保持 8 字节对齐(即地址是 8 的倍数)。为啥要8字节对齐?
原因 | 说明 |
---|---|
硬件要求 | Cortex-M 的某些指令(如浮点运算、原子操作)需要 8 字节对齐的栈。 |
性能优化 | 对齐的栈能提高内存访问效率(尤其是双字或浮点寄存器操作)。 |
ABI 规范 | ARM Architecture Procedure Call Standard (AAPCS) 要求栈必须 8 字节对齐。 |
如果未对齐,可能触发 UsageFault
(硬错误)或数据访问异常。
对于上面的代码中,我手动加了注释,其中前三条ldr指令从操作目的是取出MSP初始值。其中的原理是这样的,地址0xE000ED08就是寄存器SCB->VTOR的地址。
SCB->VTOR寄存器
这个寄存器的目的是告诉我们,中断向量表存放在哪里了。所以取0xE000ED08这个地址的数值,就是把中断向量表的地址读出来。有了中断向量表的地址,我们就可以找到中断向量表。而中断向量表的排序是这样的:
可见,MSP的初始值所在的地址偏移量是0,于是乎,我们拿到中断向量表的地址,把里面存放的内容拿出来,就是拿出来了MSP的初始值。不知道大家理解了没有。
接下来就是把MSP的初始值写进MSP寄存器,再后来就开启中断,触发SVC中断来调用第一个任务。SVC也叫请求管理调用,SVC异常也叫SVC指令触发。
SVC中断服务函数
上面触发了SVC中断
,希望可以调用第一个任务。这里看一下中断处理函数。
#define vPortSVCHandler SVC_Handler
__asm void vPortSVCHandler( void )
{
PRESERVE8
/* Get the location of the current TCB. */
ldr r3, =pxCurrentTCB
ldr r1, [r3]
ldr r0, [r1]
/* Pop the core registers. */
ldmia r0!, {r4-r11, r14}
msr psp, r0
isb
mov r0, #0
msr basepri, r0
bx r14
}
同样有8字节对齐指令PRESERVE8
,用途已经讲过了。
上图描述了汇编ldr r1, [r3]
是为了获取任务控制块的首成员的地址;下一行的ldr r0, [r1]
是为了获取首成员的内容。而首成员的内容是任务的栈顶指针。
下面接着分析:
其中isb
,是指令同步屏障指令,意思是确保前面的指令都执行完成,再执行下面的指令。
任务栈内容(重要)
地址区域及指向 | 具体内容 |
---|---|
(高地址) | xPSR 寄存器(0x01000000) |
任务函数地址(PC 寄存器) | |
任务错误退出函数地址(LR 寄存器) | |
R12 寄存器(预留) | |
R3 寄存器(预留) | |
R2 寄存器(预留) | |
R1 寄存器(预留) | |
任务函数参数(R0 寄存器) | |
对于 ARM Cortex - M3 内核,无 “EXC_RETURN 对应项” | EXC_RETURN(0xFFFFFFFD) |
R11 寄存器(预留) | |
R10 寄存器(预留) | |
R9 寄存器(预留) | |
R8 寄存器(预留) | |
R7 寄存器(预留) | |
R6 寄存器(预留) | |
R5 寄存器(预留) | |
栈顶指针pxTopOfStack最开始指向的位置 | R4 寄存器(预留) |
(低地址) |
在vPortSVCHandler
获取到栈顶指针之后,栈顶指针指向的就是上表中的R4寄存器”。之后从这个位置,一边出栈,一边向上递增地址,每次递增4字节。一直递增到EXC_RETURN这个位置,将这个值写进R14,之后就指向了上面的“任务函数参数”位置。
msr psp, r0
是将当前栈顶指针的值,写进psp
寄存器(进程堆栈指针),之后在跳转的时候,硬件自动完成剩余的出栈。
EXC_RETURN
而R14寄存器写的EXC_RETURN值是 portINITIAL_EXEC_RETURN
#define portINITIAL_EXEC_RETURN ( 0xfffffffd )
对照代码更容易理解表格中的内容。
EXC_RETURN只有6个合法的值。
EXC_RETURN 值(Hex) | 使用 FPU? | 目标模式 | 使用的栈指针 | 适用场景 |
---|---|---|---|---|
0xFFFFFFF1 | ❌ 否 | Handler 模式 | MSP | 中断服务例程(ISR)返回 |
0xFFFFFFF9 | ❌ 否 | Thread 模式 | MSP | 特权级线程(非任务代码) |
0xFFFFFFFD | ❌ 否 | Thread 模式 | PSP | FreeRTOS 任务切换(无浮点) |
0xFFFFFFE1 | ✅ 是 | Handler 模式 | MSP | 使用 FPU 的中断返回 |
0xFFFFFFE9 | ✅ 是 | Thread 模式 | MSP | 特权级线程(带浮点上下文) |
0xFFFFFFED | ✅ 是 | Thread 模式 | PSP | FreeRTOS 任务切换(带浮点支持) |
我们代码里使用的是:0xFFFFFFFD
无浮点模式。而Cortex-M3内核的芯片没有浮点运算单元MPU,所以 在下面的代码中没有r14。
/* Pop the core registers. M4或者M7内核*/
ldmia r0!, {r4-r11, r14}
/* Pop the core registers. M3内核*/
ldmia r0!, {r4-r11}
于是对比一下 0xFFFFFFFD
和 0xFFFFFFED
,可以知道最后一个都是D
,而 0xFFFFFFFD
表示无FPU,而 0xFFFFFFED
表示有FPU。我们初始化的时候,默认是无FPU的。
EXC_RETURN 位域详解
位域 | 名称/含义 | 值 | 说明 |
---|---|---|---|
31:28 | 固定前缀 | 0xF | 必须为 1111 ,标识合法的 EXC_RETURN 。 |
27:5 | 保留位 | 1 | 必须全为 1 (ARM 保留,未来可能扩展)。 |
4 | FPCA | 0/1 | 1 :浮点上下文已保存(使用 FPU);0 :无浮点操作。 |
3 | MODE | 0/1 | 0 :返回到 Handler 模式;1 :返回到 Thread 模式。 |
2 | SPSEL | 0/1 | 0 :使用 MSP(主栈指针);1 :使用 PSP(进程栈指针)。 |
1:0 | 固定后缀 | 01 | 必须为 01 (与 0b1 组合形成 EXC_RETURN 的合法低两位)。 |
关键位的作用
-
FPCA (Bit4)
- 浮点上下文活跃标志:
1
:表示异常发生时,浮点寄存器(S16-S31)已自动压栈,返回时需恢复。0
:无浮点操作,跳过浮点寄存器恢复。
- 检查方法:
tst lr, #0x10 ; 测试 LR 的 Bit4
- 浮点上下文活跃标志:
-
MODE (Bit3)
- 决定返回后 CPU 的模式:
0
:Handler 模式(用于中断/异常)。1
:Thread 模式(用于普通任务)。
- 决定返回后 CPU 的模式:
-
SPSEL (Bit2)
- 选择返回后使用的栈指针:
0
:MSP(主栈指针,内核和中断使用)。1
:PSP(进程栈指针,任务使用)。
- 选择返回后使用的栈指针:
非法值后果
若 EXC_RETURN
不是上述 6 个值之一,会触发 HardFault 或 UsageFault。
调试提示
- 在调试器中观察 LR 寄存器,确认其值为合法
EXC_RETURN
。 - 若任务使用 FPU 但
EXC_RETURN
未标记(Bit[4]=0),会导致浮点数据丢失!
回顾一下宏定义:
#define portINITIAL_EXEC_RETURN ( 0xfffffffd )
将其每一位对应到上面的分析,于是得出下面的结论。
第一个任务被调用
接着分析函数功能:下面的这些寄存器会在退出中断的时候,自动恢复到CPU的寄存器中。(别忘了我们现在还在SVC中断处理函数中哦!这些寄存器会在退出中断的时候自动恢复,无需我们手动操作。)而且SVC中断只在任务调度器这里调用一次,以后不会再使用。
在汇编bx r14
代码执行之后,会跳转到任务的任务函数中运行,于是:第一个任务被启动了!
仿真分析验证
任务控制块的地址给了任务句柄?
首先我们了解一个事。对下面的调试有帮助。
在任务的创建过程中会调用下面的函数:
在这个函数内部退出函数之前,有下面的一行代码。将TCB的地址赋值给了任务句柄。这样等任务创建完毕,可以通过任务句柄访问到任务的TCB。
调试细节
先在任务的创建中,将需要观察的变量加入观察框中,比如下面图中的:开始任务的任务句柄、任务处理函数、软件定时器的任务句柄、任务处理函数。
之后在函数vPortSVCHandler
开头打断点。
pxCurrentTCB是TCB类型的指针,它里面存放着优先级最高的任务的TCB地址。具体定义在下面:
PRIVILEGED_DATA TCB_t * volatile pxCurrentTCB = NULL;
谁是优先级最高的任务
于是,我们看到,优先级最高的是软件定时器任务!
为啥它最高?我们在开启调度器之前,手动创建了一个任务,就是startTask,优先级是1。而操作系统自己创建了2个任务,一共是空闲任务,一个是软件定时器任务。空闲任务的优先级是0,最低;但软件定时器任务的优先级是相对来说最高的。请看下面:
于是,看得出来,软件定时器的优先级最高,所以现在pxCurrentTCB
的值就是软件定时器的TCB地址。另外,软件定时器的优先级往往设置的比较高,最高为:configMAX_PRIORITIES-1
。
任务栈顶地址
调用内存管理,可以看到pxCurrentTCB
里面存放的内容,具体的读法就是20 00 0C 2C。现在已经存放在了R0寄存器。【也有人叫”栈底指针“,因为是任务栈的底部】
任务栈的具体内容及恢复到CPU寄存器
下一步看看这些值被写进寄存器。
从上图分析可以得知,各寄存器的值都已经明了了。
打开中断,进入第一个任务的任务函数
上面的寄存器值与我们的预期一致。
参考文献
《Cortex-M3权威指南》
《Cortex-M3和M4权威指南》
《正点原子-STM32F4 FreeRTOS开发手册》
至此第一个任务就被启动了,有没有感到浑身畅快?哈哈哈……感到有帮助您就点赞、收藏、关注!谢谢!