FreeRTOS 核心原理:任务是怎么"凭空"来回切换的(Cortex-M4 视角)
先说一个几乎没人明说的诡异事实:在 FreeRTOS 里,你写的每个任务都是一个 for(;;) 死循环,里面没有一行”我该把 CPU 让出来了”的代码——但几个任务却在自动轮流执行。
CPU 是单核,同一瞬间只有一条指令在执行。那当你盯着任务 A 看时,任务 B 的”另一半”正在哪?它没跑完,也没消失——它被完整地冻结在某一行上,等着某个看不见的东西把它叫醒。
这篇接着上一篇 《Cortex-M4 与 MCU 开发》 的尾巴,专门回答这个”看不见的东西”:SysTick 定时器怎么敲门、PendSV 怎么换人、以及 FreeRTOS 为什么非要用”故意触发一次异常”这么绕的方式,来完成全世界最简单的一件事——换一套寄存器。
L1:先让它跑起来——FreeRTOS 的最小程序
你在第一层。目标:用最小代码跑起两个任务,先建立”RTOS 到底长什么样”的地图感。
先把话挑明:FreeRTOS 里,任务 = 一个永不返回的 C 函数。你写两个”看起来永远在跑”的函数,内核负责让它们轮流跑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void vTaskLED1(void *pvParameters)
{
for (;;) {
LED1_ON();
vTaskDelay(500 / portTICK_PERIOD_MS); /* 让出 CPU 500ms */
LED1_OFF();
vTaskDelay(500 / portTICK_PERIOD_MS);
}
}
void vTaskLED2(void *pvParameters)
{
for (;;) {
LED2_ON();
vTaskDelay(250 / portTICK_PERIOD_MS);
LED2_OFF();
vTaskDelay(250 / portTICK_PERIOD_MS);
}
}
int main(void)
{
xTaskCreate(vTaskLED1, "LED1", 128, NULL, 1, NULL);
xTaskCreate(vTaskLED2, "LED2", 128, NULL, 1, NULL);
vTaskStartScheduler(); /* 把控制权交给内核,永不返回 */
for (;;) { } /* 正常情况下永远走不到这 */
}
先看接口。xTaskCreate(函数, 名字, 栈大小(字), 参数, 优先级, 句柄) 注册一个任务;vTaskStartScheduler() 启动调度器——它把控制权彻底交给内核,然后永不返回。main 到这里就”死”了,从此你的工程不再有”主函数”这回事,只剩一堆被调度的任务。
再看 vTaskDelay。它和裸机时的 Delay(500) 有天壤之别:裸机延时是”CPU 干等 500ms”;vTaskDelay(500) 是”把 CPU 让出去 500ms“,这期间另一个任务在跑。这是 RTOS 的第一课,也是它和裸机最大的分水岭。
打个比方:CPU 是餐厅里唯一的厨师,任务是排队的订单。vTaskDelay 相当于客人说”我去结个账逛一圈,半小时后回来”——厨师趁这会儿去炒别人的菜。裸机的 Delay 是什么?是厨师炒这道菜时把其他客人全晾着,干等 500ms 再动下一道。
(portTICK_PERIOD_MS 只是把”多少毫秒一个节拍”换算成 tick 数——tick 是什么,L2 讲。)
到这里你已经会跑 FreeRTOS 了。但这个”两个死循环轮流执行”的现象,藏着几个你现在回答不了的问题——
L2:懂原理——任务凭什么被”叫停”
到这一层,问题变成:底下到底发生了什么?两个死循环,谁按的暂停键?
先戳破一个幻觉:你的任务根本不知道自己被切换过。 任务 A 从头到尾”以为”自己一口气跑完了整个 for(;;)——其实它每执行一小段就被切走、又切回来,中间无数次”时间偷跑”。就像睡着的人不觉得时间流逝:任务被暂停时连它自己都感知不到,因为暂停期间没有 CPU 在替它计时。这正是上一篇文章结尾那句”故意触发一次异常来换栈”里”换栈”二字的含义。
要理解”叫停”,先要知道 FreeRTOS 给每个任务准备了三样东西:
函数 + 栈 + TCB
- 函数:任务的”剧本”(就是 L1 那个死循环)
- 栈:任务自己的内存区域,放局部变量和调用链——任务的”记忆”
- TCB(Task Control Block,任务控制块):一段数据结构,记着任务名、优先级、状态,还有最关键的一个字段——它上次执行到哪,栈顶指针是多少
关键在于”栈”。任务切换的本质是:把 CPU 上一组寄存器的值,存进当前任务的栈;再把下一个任务栈里存着的那组值,装回寄存器。 那,谁让这个动作发生?
两个硬件角色:SysTick(敲门)与 PendSV(换人)
Cortex-M4 内核自带一个 24 位的倒计时定时器 SysTick(地址在 0xE000E010 一带,属于内核私有外设区)。它每数到 0 就触发一次 SysTick 异常——这就是”节拍(tick)”。FreeRTOS 默认每 1ms 响一次(configTICK_RATE_HZ = 1000)。
tick 来了,内核的定时处理函数 xPortSysTickHandler 做两件事:
- tick 计数加一,顺便查账:谁的
vTaskDelay到期了?谁等的信号量可以给了?有,就把它唤醒(挪进就绪列表)。 - 如果被唤醒的任务优先级比当前任务高——给 PendSV 记一个”待办”,然后马上返回,绝不在中断里多待。
这时二号演员出场。PendSV(Pendable Service Call,可挂起的服务调用)是 Cortex-M 里一个特殊的软件异常:内核不直接调用它,而是往 ICSR 寄存器(0xE000ED04)某一位写 1 来”挂起”它。挂起不等于立刻执行——它排在最末位,等所有更紧急的事都处理完才轮得到。
FreeRTOS 在 PendSV 异常处理函数里干的,就是那个”换人”动作。简化后的伪汇编一眼看穿:
xPortPendSVHandler:
mrs r0, psp ; r0 = 当前任务的进程栈指针
stmdb r0!, {r4-r11} ; 把 r4~r11 压进当前任务的栈(前 8 个寄存器硬件已压)
ldr r3, =pxCurrentTCB
ldr r2, [r3]
str r0, [r2] ; 栈顶存回当前任务 TCB —— 现场保存完毕
; …… 从就绪列表选出下一个任务,更新 pxCurrentTCB ……
ldr r0, [r3] ; r0 = 新任务的 TCB
ldr r0, [r0] ; r0 = 新任务上次存下的栈顶
ldmia r0!, {r4-r11} ; 从新任务的栈弹回 r4~r11
msr psp, r0 ; 进程栈指针切到新任务 —— 换栈完成
bx lr ; 异常返回,硬件自动弹出前 8 个寄存器
这里有个必须点破的省力设计:Cortex-M 在异常进入时,硬件会自动把 8 个寄存器(R0–R3、R12、LR、PC、xPSR)压进当前栈——正是上一篇 《Cortex-M4 与 MCU 开发》 里讲的”中断自动压栈”。所以任务切换的寄存器搬运,硬件已经替你干了一半(8 个),软件只需再搬另一半(R4–R11)。整段切换十几条指令,换一个任务 = 换一套寄存器 + 换一个栈指针。Cortex-M 当年把”异常入口自动压栈”做进硬件,很大程度上就是为了让 RTOS 的切换既快又省。
(用带 FPU 的 M4F 还有一步”懒人算术”:浮点寄存器默认先不保存,等新任务真用浮点指令时才触发补存——这叫 lazy stacking,免得每个任务切换都为可能用不上的浮点买单。细节不影响主线。)
阻塞与唤醒:任务怎么”退场”和”返场”
拼图到此完整。vTaskDelay(500) 内部发生了什么?它把当前任务从”就绪列表”挪到”延时列表”,记下”500 个 tick 后叫我”,然后触发 PendSV 换人。此后每个 tick,SysTick 处理函数都查延时列表:到期的任务挪回就绪列表;它优先级更高,就挂起 PendSV 去抢 CPU。
这解释了 L1 的现象:任务 A 的”500ms 让出”,其实是在 SysTick 里被人叫醒、在 PendSV 里被人换回去。两个任务的”轮流”根本不是谁让谁,是 SysTick 每 1ms 敲一次门、PendSV 按优先级换人。
顺着这个模型,你能解释一个经典大坑:为什么中断里不能调 vTaskDelay? 因为中断里没有”任务”——vTaskDelay 要把”当前任务”挪进延时列表,而中断跑在中断栈上,没有自己的 TCB。让一个不存在的人去睡觉,系统当场死给你看(多数是断言失败或 HardFault)。中断里想跟任务同步,得用带 FromISR 后缀的接口(xQueueSendFromISR 之类),它们是专为”没有任务上下文”的地方准备的。记住这条红线:任务里用普通 API,中断里用 FromISR API。
到这里你已经懂原理了。但还有一层没揭开:为什么非要用 PendSV 这么绕的方式换人? 直接趁 SysTick 中断在里面切换不行吗?
L3:想得透——为什么偏偏是 PendSV
到这一层,问题变成:这套设计是理所当然的吗?换掉行不行?
先回答上层留下的悬案。为什么切换必须放在 PendSV,而不是直接在 SysTick 中断里做?
想象一个场景:UART 正在收发数据,CPU 正跑在 UART 的中断服务程序(ISR)里。这时 SysTick 到了——它是个普通中断,若优先级比正在跑的 ISR 高,会抢断正在执行的 ISR(这叫中断嵌套)。如果切换动作写在 SysTick 里,你就在”UART ISR 执行到一半”时换了栈。糟糕之处在于:UART ISR 是从某个任务身上”压”进来的,被它打断的那个任务的现场(返回地址、寄存器)就存在那根任务栈上;而你把 PSP 换成了另一个任务的栈——等 UART ISR 想按原路返回时,从栈上摸到的全是别人的数据。
解决办法就是 PendSV,而且必须是最低优先级:把”该换人”推迟到所有中断都处理完、栈处于最干净状态的时刻。ISR 里想换人怎么办?它只做一件事——挂起 PendSV 这个”待办”,然后继续跑完自己的收尾;一切尘埃落定,PendSV 才被真正执行。把”该换人”和”真换人”拆开,换人永远发生在最安全的时刻。 这个”推迟到安全时刻”的套路,是 FreeRTOS 在 Cortex-M 上立得住的根基。
(顺带一个反直觉冷知识:FreeRTOS 的调度器平时在睡觉。 绝大多数时间 CPU 跑的都是你的任务;内核代码只在三种时刻被”请”出来:tick 中断、任务主动阻塞(调 vTaskDelay 等)、某任务或 ISR 唤醒了更高优先级的任务,跑完一截马上回去睡。所以”RTOS 内核很耗资源”是常见误解——它大部分时间根本不在运行。)
本质:FreeRTOS 到底是什么
去掉所有术语,FreeRTOS 做的是这么一件事:
把”一段正在计算的程序”拆成”现场”(寄存器 + 栈)和”进度”(PC),让这个现场可以随时被摘下来、换一个装上去;决定什么时候摘、换谁上去的,是一张按优先级排好的就绪名单,外加一个硬件定时器兜底。
这句话拆开,每个词都能对上前面两层的机制:现场 = 栈 + TCB;摘/装 = PendSV 那十几条指令;优先级名单 = 就绪列表;定时器兜底 = SysTick。任务 = 一个可以暂停/恢复的执行状态,暂停键由硬件和内核按下。
如果你读过本站 《Python 生成器:调用一个函数,函数体却一行都没执行》,这个思想你一定眼熟:生成器把”函数执行到一半的状态”变成可以拿在手里、随时暂停恢复的东西——任务就是同一个思想,只是把暂停键从 yield 换成了硬件定时器 + 中断。《Python 协程》 里事件循环来回调度协程更是如出一辙:把别人从 CPU 上换下去,自己上来跑一阵。区别只在:Python 的调度是任务主动 await 让出,FreeRTOS 有硬件定时器强行抢断——连”让”都可以不让。
再追问一层设计权衡——为什么是抢占式 + tick 驱动,而不是显然更简单的方案?
被否决的方案一:协作式调度(任务自己主动让出)。 最”听话”的方案:任务运行到某个 yield() 才切换,系统简单到极致。失败场景很残酷:一个任务里写了个 while(1) 空转(忘了让出),整个系统当场冻结,所有任务永久饿死。抢占式用硬件定时器兜底:坏任务顶多浪费自己一个时间片,到点照样被切走。这是”系统不会因为一个任务的死循环而崩溃”的根本保障。
被否决的方案二:切换直接写在 tick 中断里,不要 PendSV。 失败场景前面讲过:中断嵌套时换栈,会让别的 ISR 在”不属于自己的栈”上收尾。PendSV 把”换人”推迟到安全时刻,付的代价是切换要等一等(多几十个周期),换来的是”绝不切错时刻”的确定性。
被否决的方案三:不用 tick,纯事件驱动。 任务只在”有事发生”时才被唤醒,平时 CPU 睡大觉。省电、开销小,但失败场景同样清楚:周期性的需求做不了——”每 5ms 扫一次按键”“每 1ms 跑一次电机控制”,没有定时器兜底就没有”每 N 毫秒一次”这回事,只有”哪有事哪醒”。FreeRTOS 选择 tick 驱动,等于默认了”确定性优先于省电”。(电池设备想省电?那是裸机 + 深度睡眠的主场,上一篇的决策表里写过。)
顺带交代 FreeRTOS 的来历:2003 年,英国工程师 Richard Barry 嫌商业 RTOS 又贵又封闭,一个人写出了 FreeRTOS 免费分发;2017 年 AWS 接手养护,2018 年改为 MIT 许可,从此成为物联网设备上装机量最大的 RTOS。今天你在 STM32、ESP32、树莓派 Pico 上看到的”RTOS 选项”,九成是它。
动手环节:亲眼看见”换人”
下面是全文最重要的实验——一个在任何 C 编译器上都能跑的”伪任务切换”,用 setjmp / longjmp 模拟 PendSV 换人。setjmp 把 CPU 现场存进一个缓冲,longjmp 把它装回去——这俩就是用户态的”保存现场 / 恢复现场”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdio.h>
#include <setjmp.h>
static jmp_buf sched, a, b, done; /* 四个"现场":调度器、任务A、任务B、收尾 */
static int iA = 0, iB = 0; /* 各任务自己的进度("私有栈"里的局部变量) */
static int left = 2; /* 还有几个任务没干完 */
void taskA(void)
{
if (!setjmp(a)) { printf("A 首次进入:存好现场,回调度器报到\n"); longjmp(sched, 1); }
for (; iA < 2; iA++) {
printf("A 正在干活(第 %d 轮)\n", iA + 1);
if (!setjmp(a)) longjmp(b, 1); /* 存好现场,切给 B */
}
printf("A 干完了\n");
if (--left) longjmp(b, 1); /* B 还没完,把 CPU 让给它 */
longjmp(done, 1);
}
void taskB(void)
{
if (!setjmp(b)) { printf("B 首次进入:存好现场,回调度器报到\n"); longjmp(sched, 1); }
for (; iB < 2; iB++) {
printf("B 正在干活(第 %d 轮)\n", iB + 1);
if (!setjmp(b)) longjmp(a, 1); /* 存好现场,切给 A */
}
printf("B 干完了\n");
if (--left) longjmp(a, 1);
longjmp(done, 1);
}
int main(void)
{
printf("=== 调度器启动 ===\n");
if (!setjmp(sched)) taskA(); /* 第一次:让 A 来报个到 */
if (!setjmp(sched)) taskB(); /* 第二次:让 B 来报个到 */
printf("=== 两个任务就绪,开始轮流执行 ===\n");
if (!setjmp(done)) longjmp(a, 1); /* 存好收尾入口,恢复 A 的现场 */
printf("=== 收尾:所有任务跑完,调度器退出 ===\n");
return 0;
}
先预测再看答案。 编译跑之前,先猜三件事:
- 输出里 A 和 B 各会”干活”几次?
- A 的第二次”干活”,是”从函数开头重新跑一遍”,还是”接着上次停下的地方继续”?
iA记到 2 之后,taskA的for循环怎么走?
答案——gcc -O2 文件名.c && ./a.out 直接跑,输出:
1
2
3
4
5
6
7
8
9
10
11
=== 调度器启动 ===
A 首次进入:存好现场,回调度器报到
B 首次进入:存好现场,回调度器报到
=== 两个任务就绪,开始轮流执行 ===
A 正在干活(第 1 轮)
B 正在干活(第 1 轮)
A 正在干活(第 2 轮)
B 正在干活(第 2 轮)
A 干完了
B 干完了
=== 收尾:所有任务跑完,调度器退出 ===
第 2 问的答案是接着上次——A 第二次”干活”打印的是”第 2 轮”,它没从头开始,longjmp 把控制权精确还给了上次 setjmp 停下的那一行。这就是任务切换的全部秘密:“暂停”就是把现场存进缓冲,”恢复”就是把它装回来,程序自己毫不知情。 第 3 问顺便暴露了 FreeRTOS 的一个关键设计:demo 里 iA、iB 是全局变量所以不会乱——但真实任务里这些是局部变量,必须活在各任务自己的栈里,否则两个任务共享同一个 i,互相踩数据。这就是”每个任务一个栈”的原因。
再做一个纸面实验,测你对抢占的理解。三任务,1ms 一个 tick:
- 任务 A:优先级 2,干 1ms 活 →
vTaskDelay(3),循环往复 - 任务 B:优先级 1,死循环干活,从不阻塞
- 任务 C:优先级 3,上来先
vTaskDelay(5),睡醒后干 2ms 活,然后永久阻塞
先预测:画一条从 t=0 到 t=8 的 CPU 时间线,每毫秒是谁在跑?(提示:调度器只从”就绪”的任务里挑最高优先级;睡着的任务优先级再高也不算数。)
答案:
| t | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 谁在跑 | A | B | B | B | A | C | C | B | A |
对照检查你的推理:t=0 时 C 虽然优先级最高,但它还在睡,跑的是 A;t=1~3 A 睡了,B 顶上;t=4 A 睡醒抢回 CPU;t=5 这一拍 A 又睡了、C 睡醒,C 以最高优先级连跑 2ms;t=7 C 睡死,B 接着跑;t=8 A 再醒,又一次抢走。注意 B:它是唯一”从不睡觉”的任务,却每隔几 ms 就被 A/C 抢走 CPU——抢占调度下,优先级低的忙任务会反复被打断,但只要高优先级任务会阻塞,它就永远饿不死。 因为 tick 兜底,谁也不能赖着 CPU 不放。
两个自测问题:
- 为什么切换必须放在最低优先级的 PendSV 里,而不是写在 SysTick 中断里?(答案在 L3:中断嵌套时换栈,会让别的 ISR 在别人的栈上收尾;推迟到所有中断结束才安全。)
- 任务的”记忆”存在哪?为什么不能所有任务共享一份?(答案在 L2 的动手环节:存进各任务自己的栈;共享栈 = 共享局部变量 = 互相踩数据。)
收尾地图
把整篇文章收进一张速查表:
| 场景 | 用什么 | 一句话理由 |
|---|---|---|
| 周期性干活(每 N ms 一次) | 任务 + vTaskDelay | 让出期间别人能用 CPU |
| 生产-消费、缓冲数据 | 队列(xQueueSend / xQueueReceive) | 天然阻塞 + 唤醒,还能从 ISR 用 FromISR 版本 |
| 保护共享资源 | 互斥锁(xSemaphoreCreateMutex) | 带优先级继承,防”低优先级任务拿锁不放”的反转 |
| 任务间单纯”放行” | 二进制信号量 | 一个等、一个给,唤醒语义 |
| 等一组事件里的任意一个 | 事件组 | 多个位,一次等”任一 / 全部” |
| 中断里想唤醒任务 | xxxFromISR 系列 | 中断没有任务上下文,只能走 ISR 专用接口 |
两个经验值:任务优先级数字越大越优先(0 是 idle)——注意这和中断优先级正好相反,NVIC 里是数字越小越优先,两边别记串;tick 用 1000Hz 覆盖毫秒级应用,想省中断开销降到 100Hz(10ms),代价是最小可感知延时变成 10ms。真正微秒级的时序别靠 tick 卡——那是中断和 DMA 的领域(上一篇讲过的 12 周期中断延迟)。
下一站,两条路任选。往硬件里走:去看 FreeRTOS 在 Cortex-M4 上的真正汇编级移植(portable/GCC/ARM_CM4F/ 里那个 PendSV 处理函数,全文不过几十行,你现在看得懂了),对比本文的伪代码和真汇编差在哪——尤其是 FPU 保存那一段。往系统里走:去看中断和任务之间的完整协作——FromISR 接口为什么是”安全地高优先级唤醒”的唯一入口,configMAX_SYSCALL_INTERRUPT_PRIORITY 又是怎么用中断优先级本身,承诺”ISR 绝不会打扰内核”的。把这两条线走完,FreeRTOS 在你眼里就不再是”那个 RTOS”,而是一套你能亲手重写一遍的、朴素的换人机器。