02. FreeRTOS 任务创建与栈初始化全流程
《FreeRTOS 内核源码学习》系列第 2 篇
目标:看懂一个任务从一句xTaskCreate开始,到底经历了什么才能“凭空”跑起来。
开篇:为什么任务的第一条指令能“凭空”执行
上一篇我们看清了 TCB——任务在 FreeRTOS 里的“身份证”,内核用它来记住每个任务的所有信息和当下的状态。但这里有个问题:如果是正在跑的任务,CPU 切出去的时候,会把下一条指令的地址自动压到栈上,下次切回来就能继续跑。可一个刚创建、从来没跑过的任务呢?CPU 不知道该跳到哪里去,它的栈里也全是空的。
这时候 FreeRTOS 做了件很“狡猾”的事:它在任务栈上,手工放进了一整套假的寄存器值,看起来就像这个任务之前已经跑过、只是被中断打断了。 这里面就包含了任务函数的入口地址,以及一个精心构造的“返回地址”。等调度器第一次启动这个任务时,并不是直接跳转过去,而是触发一次特殊的异常返回,让硬件按正常流程从栈上“恢复”PC、xPSR 等寄存器——于是 CPU 就这么被“骗”到了任务的第一条指令上,整个过程天衣无缝。
这个“假现场”正是本篇的主角。我们会跟着 xTaskCreate 一行行往下追,看内核是怎么在 prvInitialiseNewTask 里填充 TCB,又是怎么在 pxPortInitialiseStack 里往空白栈上写下那关键的几个字,最后把任务安稳地挂到就绪链表里。弄懂这些,你就再也不会觉得任务的第一条指令来得“凭空”了
一、xTaskCreate 的调用链总览
1.1 三个关键阶段
xTaskCreate 一路走下去,其实就干了三件大事:
xTaskCreate( TaskFunction_t pvTaskCode, ... )
│
▼
┌──────────────────────────────┐
│ ① 内存分配 │
│ - 从堆上申请 TCB + 任务栈 │
│ (动态) 或用静态指定的数组 │
│ - pxStack 指向栈的起始地址 │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ ② TCB 填充 + 栈初始化 │
│ prvInitialiseNewTask() │
│ ├─ TCB 各字段赋值 │
│ │ uxPriority, pcTaskName, │
│ │ xStateListItem... │
│ │ │
│ └─ pxPortInitialiseStack() │ ← 本篇核心
│ 在空白栈顶手工写入: │
│ xPSR, PC, LR, R12, │
│ R3, R2, R1, R0 │
│ 返回 pxTopOfStack │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ ③ 挂入就绪链表 │
│ prvAddNewTaskToReadyList() │
│ - vListInsertEnd 插入 │
│ pxReadyTasksLists[优先级] │
│ - 更新 uxTopReadyPriority │
│ - 若优先级最高 & 调度器已启动 │
│ 则触发 taskYIELD() 请求切换 │
└──────────────────────────────┘
任务创建完成,安静地待在就绪链表中等待被调度
三句话概括这张图:
- 阶段①:只是
malloc,没什么技术含量,知道栈是从堆上分的(或者静态数组)就行。 - 阶段②:本篇的硬菜。TCB 的大部分成员是“填表”——给个名字、给个优先级、给个初始通知值。真正有讲究的是
pxPortInitialiseStack:它在空的栈上,按 ARM 异常帧的格式,手写了一套假的寄存器值,就好像这个任务刚被中断打断过一样。这个“假的现场”决定了任务第一次被调度时,CPU 会从哪里开始跑、带着什么状态跑。 - 阶段③:上一篇文章已经聊过——把 TCB 的
xStateListItem挂进对应优先级的就绪链表,调度器找人的时候就能找到它了。如果这个任务比当前在跑的任务优先级还高,并且调度器已经启动了,那就当场发起taskYIELD(),让 PendSV 把它换上去。
1.2 参数怎么传递到内核
xTaskCreate 的原型长这样(在 task.h 里):
BaseType_t xTaskCreate( TaskFunction_t pvTaskCode,
const char * const pcName,
configSTACK_DEPTH_TYPE usStackDepth,
void *pvParameters,
UBaseType_t uxPriority,
TaskHandle_t *pxCreatedTask );
六个参数,一个个过一下:
| 参数 | 含义 | 传进去之后去哪了 |
|---|---|---|
pvTaskCode |
任务函数指针 | 最终被写进栈上的 PC 位置(异常返回时跳到这里) |
pcName |
任务名(调试用) | 直接拷贝到 TCB.pcTaskName |
usStackDepth |
栈深度 | 不是字节数,传给 prvInitialiseNewTask 算栈大小 |
pvParameters |
传给任务函数的参数 | 最终被写进栈上的 R0 位置(任务入口的第一个参数) |
uxPriority |
任务优先级 | 直接赋给 TCB.uxPriority,决定挂哪个就绪链表 |
pxCreatedTask |
[出参] 返回句柄 | 指向新任务的 TCB,可以为 NULL |
重点:usStackDepth 到底是“字”还是“字节”?
很多新手在这里踩坑。看代码验证,打开 tasks.c,搜 prvInitialiseNewTask,找到这一段:
pxNewTCB->pxStack = ( StackType_t * ) pvPortMalloc( ( ( ( size_t ) usStackDepth ) * sizeof( StackType_t ) ) );
答案就藏在这行里:usStackDepth × sizeof(StackType_t) 才是实际分配的字节数。在 ARM Cortex‑M 上,StackType_t 是 uint32_t,占 4 字节。所以 usStackDepth = 128,实际分配的是 128 × 4 = 512 字节的栈空间。
记一句话:FreeRTOS 里的栈深度,单位是“字”(word),不是字节。 以后你给 usStackDepth 填数,心里记得乘以 4,就是任务的真实栈大小。
1.3 异常栈帧的约定:硬件压什么,软件管什么
这里涉及的寄存器含义在[[W02_从10个寄存器开始_读懂CortexM33内核状态]]
进入异常时,Cortex‑M 硬件会按固定顺序往当前栈(MSP 或 PSP)上自动压入 8 个字(32 位):
[高地址]
xPSR
PC ← 返回后跳到这里
LR
R12
R3
R2
R1
R0
[低地址] ← 压栈后 SP 自动更新
而剩下的 R4–R11 硬件不管,留给软件(比如 RTOS 的 PendSV)在需要时自己保存和恢复。
这个分工直接决定了:
- 任务第一次被调度:栈上只要伪造这 8 个字就够了,R4–R11 还没有使用值,不用管。
- 任务切换:PendSV 里手动压栈的部分,刚好就是 R4–R11(加上 PSPLIM 和 LR 的副本),跟硬件压栈凑成完整的一套。
下一节我们就会看到 pxPortInitialiseStack 是怎么按这个顺序把 8 个字“写死”到空栈上的。
二、TCB 的“诞生”:prvInitialiseNewTask 内部
2.1 分配内存与基本字段填充
pxStack和堆区/静态区的对应关系- 任务名、优先级、初始通知状态等
2.2 最关键的一步:调用 pxPortInitialiseStack
- 将
pxTopOfStack指向人工构造的“异常帧”顶部 - 返回值就是 TCB 里的第一个成员
三、栈上“伪造”异常返回现场:pxPortInitialiseStack 深度拆解
3.1 为什么需要在栈上手工构造一段内容?
重新回想一下 Cortex‑M 的异常行为:前面 1.3 节讲过,任何异常(包括 PendSV、SVC)发生时,硬件会自动往当前栈(MSP 或 PSP)上压入 xPSR、PC、LR、R12、R3–R0 这 8 个字。异常返回时,硬件又把这些字从栈上依次弹回寄存器,然后就接着跑 PC 指向的那条指令。
这给了 FreeRTOS 一个巨大的便利:只要我们把一个任务的“首次运行现场”按照这 8 个字的格式提前写好,将来通过一次异常返回,就能让 CPU 乖乖从那个任务的第一条指令开始执行。
换句话说,内核不是“调用”一个任务,而是“骗” CPU 从一个异常返回:栈上 PC 字段写的就是任务函数的地址,xPSR 字段写了任务的初始状态,LR 字段写了一个安全出口(出错处理函数)。剩下的 R12、R3–R0 可以随便写(比如把任务参数放在 R0)。
于是,在 prvInitialiseNewTask 快结束的时候,FreeRTOS 调用了一个很关键的函数:
pxTopOfStack = pxPortInitialiseStack( StackType_t * pxTopOfStack,
StackType_t * pxEndOfStack,
TaskFunction_t pxCode,
void * pvParameters )
这就是我们要细看的地方。
3.2 逐步拆解栈帧布局
ARMv8‑M 移植层的 pxPortInitialiseStack 在 port.c 中,函数签名是:
StackType_t * pxPortInitialiseStack( StackType_t * pxTopOfStack,
StackType_t * pxEndOfStack,
TaskFunction_t pxCode,
void * pvParameters )
注意这个版本多了 pxEndOfStack 参数——后面会被写入栈中,作为任务的 PSPLIM 值。
先看 portPRELOAD_REGISTERS == 0 的分支(默认情况,也是你以后自己看书最常碰到的样子)。
第一步:构造硬件异常帧(8 字)
pxTopOfStack--; /* 对齐预减 */
*pxTopOfStack = portINITIAL_XPSR; /* xPSR = 0x01000000 */
pxTopOfStack--;
*pxTopOfStack = ( StackType_t ) pxCode; /* PC = 任务入口地址 */
pxTopOfStack--;
*pxTopOfStack = ( StackType_t ) portTASK_RETURN_ADDRESS; /* LR = 出错返回地址 */
pxTopOfStack -= 5; /* 跳过 R12, R3, R2, R1 */
*pxTopOfStack = ( StackType_t ) pvParameters; /* R0 = 任务参数 */
这一段和之前分析的一致:按照硬件异常帧顺序,从高地址往低地址写入 xPSR、PC、LR,然后连续跳过 5 个位置(R12、R3、R2、R1),最后把 pvParameters 放在 R0。这里的 portTASK_RETURN_ADDRESS 通常是 prvTaskExitError,和之前说的一样——正常情况下任务不该返回,如果返回了就跳进错误处理函数。
值得注意的是 pxTopOfStack-- 开头那个单独的减一操作。注释写得很清楚:“Offset added to account for the way the MCU uses the stack on entry/exit of interrupts.” 有的 Cortex‑M 实现会在异常进入时额外消耗一个字做栈对齐,所以这里提前预留。
第二步:预留软件保存区(R4–R11 + EXC_RETURN + PSPLIM)
pxTopOfStack -= 9; /* 跳过 R11..R4, EXC_RETURN */
*pxTopOfStack = portINITIAL_EXC_RETURN; /* EXC_RETURN 初始值 */
pxTopOfStack--;
*pxTopOfStack = ( StackType_t ) pxEndOfStack; /* PSPLIM = 栈结束地址 */
这里连续预留了 9 个位置,对应 R11–R4(8 个寄存器)再加上一个 EXC_RETURN 槽位。
接下来的两行很有意思:
-
portINITIAL_EXC_RETURN被写入 EXC_RETURN 槽位。这个值是0xFFFFFFFD,表示"返回线程模式、使用 PSP、不切安全状态"。第一个任务被 SVC 启动时,这个值会被用到。 -
pxEndOfStack被写入 PSPLIM 槽位。PSPLIM 是 ARMv8‑M 的栈边界寄存器,CPU 会自动检查 PSP 是否越过了这个值。这里把任务的栈结束地址存下来,上下文切换时恢复即可保证栈溢出防护。
(如果使能了 TrustZone,还会再多留一个 xSecureContext 槽位,初学先不管。)
第三步:返回值
return pxTopOfStack;
最终 pxTopOfStack 指向栈的最低地址(PSPLIM 槽位),这个值会被赋给 TCB.pxTopOfStack。
补充:portPRELOAD_REGISTERS == 1(调试模式)
如果你开启了这个宏,内核会把 R12、R3、R2、R1 和 R4–R11 都赋上固定的调试值(0x12121212、0x03030303…),而不是保留随机值。这样调试的时候,看一眼寄存器就知道哪些是初始值、哪些是被程序改过的。日常使用不需要开,知道有这回事就行。
3.3 用一张图记下初始栈的样子
基于上面 portPRELOAD_REGISTERS == 0 的代码,任务刚创建完的栈内容如下(栈向下增长):
栈增长方向 ↓ 高地址
+-------------------------+
| xPSR = 0x01000000 | ← 硬件异常帧 (8字)
| PC = 任务入口地址 |
| LR = prvTaskExitError |
| R12 = (未初始化) |
| R3 = (未初始化) |
| R2 = (未初始化) |
| R1 = (未初始化) |
| R0 = pvParameters |
+-------------------------+
| R11 = (未初始化) | ← 软件保存区 (10字)
| R10 = (未初始化) |
| R9 = (未初始化) |
| R8 = (未初始化) |
| R7 = (未初始化) |
| R6 = (未初始化) |
| R5 = (未初始化) |
| R4 = (未初始化) |
| EXC_RETURN = 0xFFFFFFFD |
| PSPLIM = pxEndOfStack |
+-------------------------+ ← pxTopOfStack 指向这里(低地址)
记两句口诀:
- 硬件异常帧 = xPSR, PC, LR, R12, R3, R2, R1, R0(自高向低)
- 软件预留区 = R11, R10, …, R4, EXC_RETURN, PSPLIM(自高向低)
后面学 PendSV 切换时,你会看到 stmdb r0!, {r2-r11} 就是把 PSPLIM→R2、LR→R3、R11–R4 压到这 10 个槽位上。现在栈上已经提前留好了位置,第一次切出任务的时候,直接覆盖这些 (未初始化) 即可,不用重新调整栈指针——这就是提前预留的意义。
3.4 这 8+10 个字在第一个任务启动时如何被"消费"
vTaskStartScheduler → SVC → 读 pxTopOfStack → 设 PSP → bx lr → 硬件从 PSP 弹出:
- 先弹 R0–R3, R12, LR, PC, xPSR(硬件异常帧)
- PC 被设为任务入口地址,任务开始跑
软件保存区(R4–R11 那 10 个字)在第一次启动时用不上,但等到这个任务被 PendSV 第一次切出去的时候,就会用 stmdb 把当时真实的 R4–R11 覆盖到这些预留位置上。
四、从“创建”到“就绪”:prvAddNewTaskToReadyList
任务的大半身世(TCB 赋值 + 栈初始化)在 prvInitialiseNewTask 里已经办妥,接下来最后一步就是把它挂进就绪链表,让它正式在内核里“上户口”。这件事由 prvAddNewTaskToReadyList 一手包办,上面的代码就是它的完整实现。
4.1 挂入就绪链表(回顾上一篇)
函数一开头先 taskENTER_CRITICAL(),防止中断在修改链表时插一脚。核心就一句,藏在最后:
prvAddTaskToReadyList( pxNewTCB );
上一篇我们翻过,这个宏/函数最终就是调 vListInsertEnd,把 pxNewTCB->xStateListItem 塞进 pxReadyTasksLists[ pxNewTCB->uxPriority ] 的末尾。同时更新 uxTopReadyPriority 之类的记录。所以不管后面调度器跑没跑,任务这会儿已经老老实实躺在一个就绪链表里了,等着被“点名叫号”。
4.2 如果这是最高优先级任务
挂完链表后,函数按“调度器是否启动”分两条路处理,目的只有一个:让 CPU 上跑着的 pxCurrentTCB 始终是当前最该跑的任务。
情况一:调度器还没启动(xSchedulerRunning == pdFALSE)
if( pxCurrentTCB == NULL )
{
pxCurrentTCB = pxNewTCB; // 第一个任务
...
}
else
{
if( pxCurrentTCB->uxPriority <= pxNewTCB->uxPriority )
{
pxCurrentTCB = pxNewTCB; // 新任务优先级≥当前任务,就换成它
}
}
如果这是系统里的第一个任务,pxCurrentTCB 直接指向它。要是先前已经有别的任务创建好了,就比较优先级:只要新任务不比当前记录的那个差,就把 pxCurrentTCB 切过来。但这只是“记下”谁最优先,并不会真的切换 CPU。 因为调度器还没开,CPU 还在 main 函数或者启动流程里跑,真正的上下文切换要等到 vTaskStartScheduler 之后。
情况二:调度器已经启动(xSchedulerRunning != pdFALSE)
if( pxCurrentTCB->uxPriority < pxNewTCB->uxPriority )
{
taskYIELD_IF_USING_PREEMPTION(); // 触发 PendSV 立即切换
}
这时候如果新任务的优先级严格高于当前正在运行的任务,那就不是改个指针这么简单了——必须马上让它上 CPU。taskYIELD_IF_USING_PREEMPTION()(实际就是执行 taskYIELD)会直接设置 PendSV 的挂起位,等下一瞬间 PendSV 异常来临,上下文切换就把新任务换上去跑。
两句话总结:
- 调度器没开:只是把最高优先级的 TCB 记在
pxCurrentTCB名下,不抢占。 - 调度器开了:一旦新任务优先级高于当前任务,当场请求 PendSV 切换,让新任务立即抢占。
而不管哪种情况,prvAddTaskToReadyList 那一行都已经把新任务挂进了就绪链表。这就是任务从“创建完成”到“等待调度”的最后一步。
五、第一个任务如何真正开始运行?——极简版 SVC 启动
5.1 调度器启动的启动
vTaskStartScheduler做三件事:创建 idle 任务、调用xPortStartScheduler、触发 SVCxPortStartScheduler主要是配置 SysTick 和 PendSV 优先级
5.2 为什么第一个任务必须通过 SVC 启动
- 不能在中断上下文里直接切换任务,必须回到线程模式
- SVC 里手工设置 PSP,指向刚才构造的栈,然后异常返回
六、本文总结 & 下篇预告
6.1 关键收获
- 任务的“第一条指令”是栈上异常帧的 PC 字段决定的
pxPortInitialiseStack人为构造了一个“刚从异常返回”的假象- 任务创建后就已经挂在就绪链表里,调度器启动只是挑一个出来跑
- 第一次启动任务是通过 SVC 异常“借道”返回的
6.2 下一篇预告
- 真正的切换:
PendSV_Handler上下文切换全解析,结合上次画的 PSP 交换图,一步步跟着寄存器走
更多推荐
所有评论(0)