调度器开启
调度器开启
FreeRTOS 中,调用 vTaskStartScheduler() 后,系统才真正进入任务调度状态。
从整体流程来看:
vTaskStartScheduler()
│
├── 创建空闲任务
│
├── 创建软件定时器任务(如果开启)
│
├── 初始化调度器状态
│
└── xPortStartScheduler()
│
├── 配置 PendSV / SysTick 优先级
├── 配置 Tick 定时器
├── 开启 FPU
└── prvStartFirstTask()
│
└── SVC
│
└── 恢复第一个任务上下文
│
▼
第一个任务开始运行需要注意:
任务调度并不只发生在
vTaskDelay()和 SysTick 中断。
更准确地说,任务切换可能发生在:
vTaskDelay()等内核 API 主动触发调度;- SysTick 中断到来后触发时间片轮转或延时任务唤醒;
- 任务阻塞、解除阻塞;
- 信号量、队列、事件组、任务通知等同步机制导致更高优先级任务就绪;
- ISR 中调用
xxxFromISR(),并请求一次任务切换。
其中 Cortex-M 上真正负责异常级任务上下文切换的核心机制通常是:
SysTick
│
└── 产生调度请求
│
▼
PendSV
│
└── 保存当前任务上下文
恢复下一个任务上下文vTaskStartScheduler()
vTaskStartScheduler() 是 FreeRTOS 启动调度器的入口。
void vTaskStartScheduler(void)
{
BaseType_t xReturn;
/* 创建空闲任务 */
#if (configSUPPORT_STATIC_ALLOCATION == 1)
{
StaticTask_t *pxIdleTaskTCBBuffer = NULL;
StackType_t *pxIdleTaskStackBuffer = NULL;
uint32_t ulIdleTaskStackSize;
/* 用户提供空闲任务所使用的 TCB 和栈 */
vApplicationGetIdleTaskMemory(
&pxIdleTaskTCBBuffer,
&pxIdleTaskStackBuffer,
&ulIdleTaskStackSize
);
xIdleTaskHandle = xTaskCreateStatic(
prvIdleTask,
"IDLE",
ulIdleTaskStackSize,
(void *)NULL,
(tskIDLE_PRIORITY | portPRIVILEGE_BIT),
pxIdleTaskStackBuffer,
pxIdleTaskTCBBuffer
);
if (xIdleTaskHandle != NULL)
{
xReturn = pdPASS;
}
else
{
xReturn = pdFAIL;
}
}
#else
{
/* 动态创建空闲任务 */
xReturn = xTaskCreate(
prvIdleTask,
"IDLE",
configMINIMAL_STACK_SIZE,
(void *)NULL,
(tskIDLE_PRIORITY | portPRIVILEGE_BIT),
&xIdleTaskHandle
);
}
#endif
/* 如果启用了软件定时器 */
#if (configUSE_TIMERS == 1)
{
if (xReturn == pdPASS)
{
/* 创建软件定时器任务 */
xReturn = xTimerCreateTimerTask();
}
else
{
mtCOVERAGE_TEST_MARKER();
}
}
#endif
if (xReturn == pdPASS)
{
/* 关闭中断 */
portDISABLE_INTERRUPTS();
/* 设置下一个任务解除阻塞的时间 */
xNextTaskUnblockTime = portMAX_DELAY;
/* 标记调度器正在运行 */
xSchedulerRunning = pdTRUE;
/* Tick 从 0 开始 */
xTickCount = (TickType_t)0U;
/* 配置运行时间统计定时器 */
portCONFIGURE_TIMER_FOR_RUN_TIME_STATS();
/* 启动与具体 CPU 架构相关的调度器 */
if (xPortStartScheduler() != pdFALSE)
{
/* 正常情况下不会执行到这里 */
}
else
{
/* 调度器被结束后可能执行到这里 */
}
}
else
{
/* 创建空闲任务或定时器任务失败 */
configASSERT(
xReturn != errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY
);
}
(void)xIdleTaskHandle;
}这个函数可以分成四个阶段:
① 创建 Idle Task
↓
② 创建 Timer Task(如果启用)
↓
③ 初始化调度器内部状态
↓
④ xPortStartScheduler()
↓
启动第一个任务为什么一定要有空闲任务?
FreeRTOS 必须保证:
只要调度器正在运行,就始终至少存在一个可以运行的任务。
这个任务就是:
Idle Task它的优先级通常是:
tskIDLE_PRIORITY如果所有用户任务都处于阻塞态:
Task A Blocked
Task B Blocked
Task C Blocked
↓
没有用户任务 Ready
↓
Idle Task Ready
↓
执行 Idle Task所以 Idle Task 是 FreeRTOS 调度体系中的“兜底任务”。
它还承担一些系统级工作,例如:
- 回收被删除任务的资源;
- 执行 Idle Hook;
- 配合 Tickless Idle 实现低功耗。
静态创建和动态创建
FreeRTOS 支持两种任务内存分配方式。
静态创建
xTaskCreateStatic(...)任务的:
TCB
+
Stack由用户提前准备。
例如:
StaticTask_t xIdleTaskTCB;
StackType_t xIdleTaskStack[128];这种方式不会调用 FreeRTOS Heap。
动态创建
xTaskCreate(...)由 FreeRTOS 从 Heap 中申请:
TCB
+
Stack例如:
xTaskCreate()
│
├── pvPortMalloc()
│ ↓
│ TCB
│
└── pvPortMalloc()
↓
Stack因此,如果 Heap 空间不足:
xTaskCreate() == errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY调度器就无法正常启动。
xNextTaskUnblockTime
这是 FreeRTOS 中非常重要的一个变量。
它表示:
当前所有阻塞任务中,最早应该解除阻塞的那个时间点。
例如:
当前 Tick = 100
Task A:
延时到 Tick 150
Task B:
延时到 Tick 180
Task C:
延时到 Tick 250那么:
xNextTaskUnblockTime = 150;当 SysTick 到来:
xTickCount++
│
▼
xTickCount >= xNextTaskUnblockTime ?
│
├── NO → 没有任务到期
│
└── YES → 有任务应该解除阻塞这样就不需要每一个 Tick 都扫描所有延时任务。
为什么启动调度器时设置为 portMAX_DELAY?
启动调度器时:
xNextTaskUnblockTime = portMAX_DELAY;表示:
当前没有需要立即检查的延时任务。
portMAX_DELAY 可以理解成一个非常大的 Tick 值。
例如:
0xFFFFFFFF因此:
xTickCount
↓
不断增加
portMAX_DELAY
↓
非常遥远启动阶段不会因为某个“尚未存在的延时任务”而触发解除阻塞逻辑。
注意:
xNextTaskUnblockTime并不是“任务解锁时间表”,而是一个快速定位最近唤醒时间的缓存变量。真正的阻塞任务仍然维护在延时列表中。
portCONFIGURE_TIMER_FOR_RUN_TIME_STATS()
代码中还有:
portCONFIGURE_TIMER_FOR_RUN_TIME_STATS();这是用于配置 FreeRTOS 的运行时间统计功能。
例如:
Task A 35%
Task B 20%
Task C 15%
Idle 30%FreeRTOS 需要一个比 Tick 更精细的时间基准,统计每个任务到底运行了多久。
这个宏通常需要用户根据具体 MCU 自己实现。
例如可以使用:
TIM
DWT CYCCNT
其他高精度定时器具体使用哪一个取决于 MCU 和工程配置。
xPortStartScheduler()
完成 FreeRTOS 内核层面的准备后:
xPortStartScheduler();进入:
portable/对应架构/中的 Cortex-M 相关代码。
它负责:
配置 Cortex-M 硬件
↓
配置 PendSV
配置 SysTick
配置 Tick 定时器
配置 FPU
↓
启动第一个任务以 Cortex-M4F 为例:
BaseType_t xPortStartScheduler(void)
{
configASSERT(configMAX_SYSCALL_INTERRUPT_PRIORITY);
/* 配置中断优先级 */
/* 设置 PendSV / SysTick 优先级 */
/* 配置 MPU(如果使用) */
/* 配置 Tick 定时器 */
prvSetupTimerInterrupt();
/* 初始化临界区嵌套计数 */
uxCriticalNesting = 0;
/* 开启 FPU */
vPortEnableVFP();
/* 开启 FPU lazy stacking */
*(portFPCCR) |= portASPEN_AND_LSPEN_BITS;
/* 启动第一个任务 */
prvStartFirstTask();
return 0;
}这里最重要的一点是:
xPortStartScheduler()已经不只是 FreeRTOS 通用内核代码,而开始进入具体 CPU 架构相关代码。
因此你会看到大量:
SCB
NVIC
SysTick
PendSV
SVC
PSP
MSP
BASEPRI
FPUPendSV 和 SysTick 的优先级
FreeRTOS 会配置:
PendSV
SysTick通常为较低优先级。
这是因为:
普通外设中断
↓
尽快处理
↓
PendSV
↓
进行任务切换任务切换本身不是最紧急的事情。
所以让 PendSV 保持最低优先级,可以避免任务切换打断更重要的外设 ISR。
这也是 Cortex-M + FreeRTOS 的经典设计:
高优先级
↑
│ 外设中断
│
│
│
│ SysTick
│
低 │ PendSV
↓为什么任务切换主要使用 PendSV?
假设 SysTick 到来:
SysTick
↓
判断是否需要切换任务
↓
需要
↓
触发 PendSV
↓
PendSV Handler
↓
保存当前任务上下文
↓
选择下一个任务
↓
恢复下一个任务上下文这样设计的好处是:
SysTick 负责产生调度时机,PendSV 负责真正执行上下文切换。
这两个角色不要混在一起。
FPU
FPU:
Floating Point Unit即浮点运算单元。

例如:
float a = 1.2f;
float b = 2.3f;
float c = a * b;如果 MCU 没有硬件 FPU,编译器可能需要调用软件浮点库完成计算。
如果 MCU 有 FPU:
CPU
│
├── 整数运算
│
└── FPU
↓
浮点运算例如 STM32F4、F7 等 Cortex-M4F/M7F 芯片通常带有硬件浮点单元。
vPortEnableVFP()
FreeRTOS 需要确保 FPU 已经打开。

核心代码:
ldr.w r0, =0xE000ED88
ldr r1, [r0]
orr r1, r1, #(0xf << 20)
str r1, [r0]
bx r14其中:
0xE000ED88是:
SCB->CPACR即:
Coprocessor Access Control Register设置:
CP10
CP11允许访问 FPU。
因此:
CPACR
│
├── CP10 = 11
└── CP11 = 11
↓
FPU EnabledFPU 上下文为什么需要保存?
假设:
Task A
↓
正在使用 FPU
↓
S16 = A的数据
↓
发生任务切换
↓
Task B
↓
也使用 FPU
↓
S16 被修改如果不保存:
Task A 的 S16
↓
被 Task B 覆盖Task A 恢复以后:
S16 ≠ 原来的值浮点计算结果就会错误。
所以任务切换时必须保护浮点上下文。
FPU 寄存器的保存
Cortex-M4F 中主要涉及:
S0 ~ S31
FPSCR其中:
S0 ~ S15属于异常进入时可能由硬件处理的浮点上下文。
而:
S16 ~ S31属于需要软件进一步保护的部分。
因此可以粗略理解成:
任务切换
│
├── 硬件处理
│ S0-S15
│ FPSCR
│
└── 软件处理
S16-S31具体是否真的发生浮点上下文保存,还受到 Cortex-M FPU 的 Lazy Stacking 机制影响。
惰性压栈 Lazy Stacking
如果每次进入中断都保存:
S0-S15
FPSCR需要额外保存:
17 × 4 = 68 Bytes如果这个中断根本没有使用 FPU:
保存68字节
↓
纯浪费因此 Cortex-M 提供了:
Lazy Stacking即:
先不急着保存浮点上下文,只有真正需要时才保存。
相关配置:
*(portFPCCR) |= portASPEN_AND_LSPEN_BITS;其中:
ASPEN
Automatic State Preservation Enable
LSPEN
Lazy State Preservation Enable因此可以理解成:
进入异常
↓
是否需要浮点上下文?
│
├── 不需要 → 不保存
│
└── 需要
↓
延迟保存
↓
保存浮点上下文这可以降低不使用 FPU 的中断进入/退出开销。
prvStartFirstTask()
完成硬件配置后:
prvStartFirstTask();真正开始启动第一个任务。
核心代码:
ldr r0, =0xE000ED08
ldr r0, [r0]
ldr r0, [r0]
msr msp, r0
cpsie i
cpsie f
dsb
isb
svc portSVC_START_SCHEDULER
nop
nop这段代码非常重要。
VTOR

0xE000ED08对应:
SCB->VTOR即:
Vector Table Offset Register它保存:
当前向量表的基地址。
当CPU上电时,第一步就是去拿取它VTOR寄存器里面的值,默认是0,然后被映射到0800000的位置
例如:
SCB->VTOR
│
▼
0x08000000
│
▼
+----------------------+
| 初始 MSP | ← [0]
+----------------------+
| Reset_Handler | ← [1]
+----------------------+
| NMI_Handler |
+----------------------+
| HardFault_Handler |
+----------------------+
| ... |
+----------------------+向量表第一项是什么?
向量表第 0 项:
[VTOR + 0]保存的是:
初始 MSP第二项:
[VTOR + 4]保存的是:
Reset_Handler所以:
ldr r0, =0xE000ED08得到:
VTOR寄存器地址然后:
ldr r0, [r0]得到:
向量表地址再:
ldr r0, [r0]得到:
向量表第一项
=
初始 MSP最后:
msr msp, r0重新设置:
MSP = 初始栈顶为什么要重新设置 MSP?
系统复位时,Cortex-M 硬件本身就会从向量表加载初始 MSP。
那么 FreeRTOS 为什么又做一次?
因为:
FreeRTOS 希望在启动第一个任务前,确保 MSP 恢复到系统启动时定义的初始值。
之后:
MSP主要承担:
异常 / 中断而任务运行时主要使用:
PSP这样就把:
内核/异常栈和:
任务栈进行了分离。
MSP 和 PSP
Cortex-M 有两个主要栈指针:
MSP
Main Stack Pointer
PSP
Process Stack Pointer可以简单理解成:
Cortex-M
│
┌────────┴────────┐
│ │
MSP PSP
│ │
异常/内核 任务运行在 FreeRTOS 的典型 Cortex-M 配置中:
异常/中断
↓
MSP
任务
↓
PSP例如:
Task A
↓
PSP_A
Task B
↓
PSP_B
Task C
↓
PSP_C每一个任务都有自己的任务栈。
Thread Mode 和 Handler Mode
Cortex-M 有两种运行模式:
Thread Mode
Handler Mode通常:
任务
↓
Thread Mode
中断 / 异常
↓
Handler Mode同时还有特权级别:
Privileged
UnprivilegedCONTROL 寄存器中的:
CONTROL[0]用于控制 Thread Mode 的特权级别。
但是需要特别注意:
并不是所有 FreeRTOS 任务默认都运行在非特权模式。
在普通 FreeRTOS Cortex-M 配置下,任务通常仍然运行在特权模式。
只有启用 MPU 等相关机制时,才会使用非特权任务模式。
因此:
__set_CONTROL(0x01);并不是普通 FreeRTOS 任务切换的必然操作,而是与特定 MPU/特权配置有关。
为什么中断使用 MSP?
假设当前:
Task A
↓
使用 PSP突然发生 SysTick:
Task A
│
│ PSP
▼
任务栈进入异常后:
Handler Mode
↓
使用 MSP硬件将异常现场保存到当前异常使用的栈中。
因此系统拥有:
任务栈
+
异常栈这样的分离结构。
向量表重定位
默认情况下,向量表可能位于 Flash:
0x08000000但是 Cortex-M 支持通过:
SCB->VTOR修改向量表位置。
例如:
SCB->VTOR = 0x20000000;之后:
异常发生
↓
CPU读取 VTOR
↓
0x20000000
↓
从新的向量表寻找 Handler这在以下场景比较常见:
Bootloader
↓
ApplicationBootloader 和 Application 可能分别拥有自己的向量表。
启动 Application 后:
SCB->VTOR = Application向量表地址;这样异常就会跳转到 Application 对应的中断处理函数。
从编译到启动:MSP 是怎么来的?
例如链接脚本定义:
_estack = 0x20020000启动文件:
__Vectors:
DCD _estack
DCD Reset_Handler
...最终烧录到 Flash:
0x08000000 → 0x20020000
0x08000004 → Reset_HandlerMCU 复位:
读取 0x08000000
↓
MSP = 0x20020000
读取 0x08000004
↓
PC = Reset_Handler之后:
Reset_Handler
↓
SystemInit()
↓
main()
↓
vTaskStartScheduler()
↓
prvStartFirstTask()于是整个启动链就连起来了。
cpsie i / cpsie f
cpsie i用于:
开启可屏蔽中断。
可以理解成:
PRIMASK = 0而:
cpsie f用于:
允许 Fault 类异常。
然后:
dsb
isb确保前面的系统状态修改按照预期生效。
其中:
DSB
Data Synchronization Barrier数据同步屏障。
ISB
Instruction Synchronization Barrier指令同步屏障。
SVC:启动第一个任务
最后:
svc portSVC_START_SCHEDULER触发:
SVC 异常这一步非常关键。
因为此时:
MSP已经恢复。
接下来 SVC Handler 会从当前任务的 TCB 中找到:
pxCurrentTCB然后找到:
pxCurrentTCB->pxTopOfStack也就是:
第一个任务预先构造好的栈顶。
为什么任务栈一开始就是“假的”?
因为任务从来没有真正执行过。
例如:
xTaskCreate(Task1, ...);此时:
Task1只是一个函数。
它还没有:
进入函数
执行第一条指令但是 FreeRTOS 会在创建任务的时候:
提前把一个“看起来像是任务已经被中断保存过一次”的栈帧伪造出来。
例如:
任务栈
┌───────────────┐
│ xPSR │
├───────────────┤
│ PC │ → Task1
├───────────────┤
│ LR │
├───────────────┤
│ R12 │
├───────────────┤
│ R3 │
├───────────────┤
│ R2 │
├───────────────┤
│ R1 │
├───────────────┤
│ R0 │
├───────────────┤
│ R4 │
├───────────────┤
│ ... │
│ R11 │
└───────────────┘这样第一次启动任务时,就可以假装:
“这个任务之前已经被保存过一次,现在把它恢复出来。”
于是 CPU 最终会从伪造的:
PC = Task1开始执行。
SVC Handler
典型 FreeRTOS Cortex-M SVC Handler 类似:
__asm void vPortSVCHandler(void)
{
PRESERVE8
/* 获取当前任务 TCB */
ldr r3, =pxCurrentTCB
ldr r1, [r3]
/* 获取任务栈顶 */
ldr r0, [r1]
/* 恢复软件保存的寄存器 */
ldmia r0!, {r4-r11}
/* PSP 指向剩余的硬件栈帧 */
msr psp, r0
isb
/* 开启中断 */
mov r0, #0
msr basepri, r0
/* 异常返回 */
orr r14, #0xd
bx r14
}SVC Handler 到底干了什么?
整个过程:
SVC异常
│
▼
找到 pxCurrentTCB
│
▼
找到 pxTopOfStack
│
▼
恢复 R4-R11
│
▼
PSP = 剩余栈帧
│
▼
设置 EXC_RETURN
│
▼
bx lr
│
▼
硬件自动恢复:
R0-R3
R12
LR
PC
xPSR
│
▼
PC = 任务入口地址
│
▼
第一个任务开始运行为什么 R4-R11 要软件恢复?
Cortex-M 异常进入时,硬件会自动保存:
R0
R1
R2
R3
R12
LR
PC
xPSR也就是:
硬件栈帧而:
R4-R11不会由异常硬件自动压入这个栈帧。
所以 FreeRTOS 自己保存和恢复:
ldmia r0!, {r4-r11}于是整个任务上下文就可以拼起来:
任务上下文
│
┌────────┴────────┐
│ │
软件保存 硬件保存
│ │
R4-R11 R0-R3,R12,LR
PC,xPSREXC_RETURN
这里:
orr r14, #0xd
bx r14不是普通的函数返回。
它实际上是在告诉 Cortex-M:
我要执行异常返回,并且返回到 Thread Mode,同时使用 PSP 恢复任务上下文。
也就是说:
bx lr遇到特殊的:
EXC_RETURN值时,CPU 会把它解释为:
异常返回命令然后自动完成硬件栈帧恢复。
第一个任务启动的完整过程
现在把所有知识串起来:
main()
│
▼
vTaskStartScheduler()
│
├── 创建 Idle Task
│
├── 创建 Timer Task
│
├── xSchedulerRunning = pdTRUE
│
├── xTickCount = 0
│
└── xPortStartScheduler()
│
├── 设置 PendSV 优先级
├── 设置 SysTick 优先级
├── 配置 Tick
├── 开启 FPU
├── 开启 Lazy Stacking
│
└── prvStartFirstTask()
│
├── 读取 VTOR
├── 获取初始 MSP
├── msr msp
├── 开启中断
│
└── SVC
│
▼
vPortSVCHandler()
│
├── 找到 pxCurrentTCB
├── 找到任务栈
├── 恢复 R4-R11
├── 设置 PSP
├── 设置 EXC_RETURN
│
▼
硬件恢复 R0-R3...
│
▼
PC = Task
│
▼
第一个任务运行第一个任务运行之后,调度是怎么发生的?
第一次启动任务使用的是:
SVC但是之后正常的任务切换,主要依赖:
SysTick + PendSV例如:
Task A 正在运行
│
▼
SysTick 到来
│
▼
xTickCount++
│
▼
判断是否有任务解除阻塞
│
▼
判断是否需要切换任务
│
▼
触发 PendSV
│
▼
PendSV Handler
│
├── 保存 Task A 上下文
│
├── pxCurrentTCB = Task B
│
└── 恢复 Task B 上下文
│
▼
Task B 运行因此可以把三者的职责记成:
SVC
↓
第一次启动任务
SysTick
↓
产生系统 Tick
↓
判断是否需要调度
PendSV
↓
真正执行任务上下文切换这是理解 FreeRTOS Cortex-M 调度器最重要的一条主线。
最后总结
vTaskStartScheduler() 本身并不是“开始执行某个任务”这么简单。
它实际上完成了:
创建系统任务
↓
初始化调度器
↓
配置 Cortex-M 硬件
↓
配置 Tick
↓
配置 PendSV
↓
准备 FPU
↓
恢复 MSP
↓
通过 SVC 恢复第一个任务的伪造上下文
↓
CPU 开始执行第一个任务而第一次任务启动和之后正常任务切换需要区分:
| 场景 | 主要机制 | 作用 |
|---|---|---|
| 启动第一个任务 | SVC | 从任务栈恢复第一个任务 |
| SysTick 到来 | SysTick | 更新时间、处理延时任务、判断是否需要调度 |
| 真正任务切换 | PendSV | 保存当前任务、恢复下一个任务 |
| 任务主动阻塞 | vTaskDelay() 等 API | 修改任务状态并触发调度 |
| ISR 中请求切换 | xxxFromISR() | 请求 PendSV 执行任务切换 |
最终可以浓缩成一句话:
vTaskStartScheduler()负责把 FreeRTOS 从“任务已经创建但还没运行”的状态,转换成“CPU 已经由调度器接管”的状态;第一次任务启动通过 SVC 完成,而之后的任务切换主要由 SysTick 产生调度时机、PendSV 完成上下文切换。

