《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_tuint32_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 移植层的 pxPortInitialiseStackport.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 都赋上固定的调试值(0x121212120x03030303…),而不是保留随机值。这样调试的时候,看一眼寄存器就知道哪些是初始值、哪些是被程序改过的。日常使用不需要开,知道有这回事就行。


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 弹出:

  1. 先弹 R0–R3, R12, LR, PC, xPSR(硬件异常帧)
  2. 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、触发 SVC
  • xPortStartScheduler 主要是配置 SysTick 和 PendSV 优先级

5.2 为什么第一个任务必须通过 SVC 启动

  • 不能在中断上下文里直接切换任务,必须回到线程模式
  • SVC 里手工设置 PSP,指向刚才构造的栈,然后异常返回

六、本文总结 & 下篇预告

6.1 关键收获

  1. 任务的“第一条指令”是栈上异常帧的 PC 字段决定的
  2. pxPortInitialiseStack 人为构造了一个“刚从异常返回”的假象
  3. 任务创建后就已经挂在就绪链表里,调度器启动只是挑一个出来跑
  4. 第一次启动任务是通过 SVC 异常“借道”返回的

6.2 下一篇预告

  • 真正的切换:PendSV_Handler 上下文切换全解析,结合上次画的 PSP 交换图,一步步跟着寄存器走
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐