FreeRTOS学习(7)——任务列表
任务状态切换
在前面为了讲清楚FreeRTOS的任务调度机制,我们引入了“进程的三种状态”,也就是经典的三状态模型。
如下图所示:

在当时,我们就提到过:
进程的三种状态只是描述CPU 资源调度问题的逻辑结构模型。
而具体到实际的操作系统实现:
竞争CPU资源的实体名称可以改变,状态的名字也可以改变,状态可以更多样化,状态切换的结构可以更复杂化....
三状态模型只是一个“最简抽象框架”。
在FreeRTOS中:
- 竞争 CPU 的实体名称不再叫“进程”,而是叫“任务(Task)”
- FreeRTOS系统中,任务的实际状态有四种:就绪、运行、阻塞以及挂起。
也就是说:
FreeRTOS 在经典三状态模型的基础上,引入了“挂起态”这一额外状态(实际上这种状态模型设计还是非常简陋的)。
因此,在 FreeRTOS 中,任务的状态切换关系模型,会比经典三状态模型更加丰富。
下图所示,即为 FreeRTOS 任务的四状态切换结构模型图:

这张图描述的是 FreeRTOS 任务的四种状态流转关系:
- 就绪态(Ready)
- 运行态(Running)
- 阻塞态(Blocked)
- 挂起态(Suspended)
首先,刚刚创建的任务处于就绪态,这是一个前提条件。
Ready ↔ Running
任务状态流转的第一条主线是:Ready ↔ Running
在单核 MCU 上(比如STM32F103C8T6),同一时刻只能有一个任务上CPU,处于 Running 状态。
这不是调度策略问题,而是硬件物理限制决定的。
因此可以得到两个基本结论:
- 调度器只会从“就绪态任务集合”中选择一个任务运行,被选中的任务,从 Ready 状态进入 Running 状态。
- 当前运行的任务在被抢占或主动让出 CPU 后,会重新回到就绪态或进入其它状态。
在实际工程中,大多数时候,处于就绪态的任务都不止一个。
这时就需要引入一个经典术语——就绪队列(Ready Queue)。
就绪队列,是用于存放所有“已经具备运行条件,但暂时未获得 CPU”的执行实体的数据结构。
注意:
“就绪队列”只是一个逻辑概念,用来表示“等待 CPU 的任务集合”。
它并不限定必须采用某种具体的数据结构形式。
在早期批处理操作系统实现时,就绪队列确实采用了 FIFO 队列实现,所以叫做“就绪队列”。
但在现代操作系统中(尤其是通用操作系统),就绪队列的实现远比数据结构队列要复杂得多。
之所以仍然称为“队列”,更多是一种历史习惯和术语沿用,而不是数据结构意义上的严格定义。
那么问题来了:
FreeRTOS 内核中的“就绪队列”究竟是如何实现的?
作为一个轻量化操作系统,它是一个简单的队列实现呢?
还是有着更精巧的结构设计?
这将是下面课程内容的重点。
在后续内容中,我们将结合源码,对其内部实现进行详细分析。
Running → Blocked → Ready
任务的运行态(Running)和就绪态(Ready)之间,可以进行双向切换。
在 FreeRTOS 中,任务的执行可以被抢占,被抢占的任务会回到就绪态,随后此任务还可以重新获取CPU执行权,重新进入运行态。
但一旦涉及到阻塞态,任务状态的切换就不再是简单的双向关系了。
FreeRTOS中的某个任务,只能在运行态进行切换到阻塞态。
所以FreeRTOS任务状态流转的第二条主线是:
Running → Blocked → Ready
这条主线,描述了任务离开 CPU 的另一种方式:主动放弃CPU执行权,进入阻塞状态。
进入阻塞态的任务,会主动让出CPU执行权,在达成阻塞条件后(比如延时时间到),就会重新回到就绪态。
特别需要注意的是:
阻塞态的任务在完成阻塞后,并不会直接回到 Running 状态,而是回到 Ready 状态,重新参与任务调度。
随后能否运行,仍然要取决于当前系统中的调度情况。
Ready ↔ Suspended
在进程的三种状态经典模型中,并没有直接提到“挂起态”。
那么什么是挂起态呢?
在 FreeRTOS 中,挂起态指的是:
任务被人为地“从调度系统中移除”。
注意这个关键词——人为。
也就是说,是程序员主动调用挂起函数,使某个任务进入挂起态。
与阻塞态不同的是:
- 阻塞态是“等待某个条件达成”;
- 挂起态则是“被明确暂停,不参与调度”;
- 挂起态的任务虽然没有被删除,但实际会被调度器忽略,永远不会获取CPU执行权。
- 被挂起的任务,只有在恢复挂起后(即调用恢复函数),回到就绪态,才能重新参与 CPU 竞争。
需要特别注意两个细节:
- 处于任意状态的任务,都可以被挂起,直接进入挂起态;
- 被挂起的任务在恢复后,会回到就绪态,类似于阻塞态任务完成阻塞后的状态转换。
总结
至此,我们已经从逻辑模型层面,把 FreeRTOS 的四种任务状态以及三条主线梳理清楚:
- Ready ↔ Running —— 解决的是 CPU 在多个任务之间如何切换;
- Running → Blocked → Ready —— 解决的是任务为何主动离开 CPU;
- Ready ↔ Suspended —— 解决的是任务如何被人为移出调度体系。
可以看到,这一整套状态流转模型,本质上仍然围绕一个核心问题展开:
调度器如何管理“就绪态任务”?
因为无论是阻塞完成,还是恢复挂起,最终都会回到 Ready 状态,重新参与 CPU 竞争。
也就是说:
所有任务状态流转的“汇聚点”,都是——就绪态。
那么问题来了:
FreeRTOS 内核究竟是如何组织这些就绪任务的?
它的“就绪队列”到底采用了怎样的数据结构?
下面,我们正式进入源码层面,用画图的方式,来分析 FreeRTOS 就绪队列的内部实现机制。
FreeRTOS内核任务状态管理数据结构
FreeRTOS是一个简易轻量化的实时操作系统,所以其内核任务状态管理的数据结构部分设计的非常简单。
包括就绪队列,整体实现参考如下源码:

这一系列定义在task.c文件内部的静态成员,本质上就是 FreeRTOS 内核用来管理任务状态的核心数据结构。
实际上我们在研究FreeRTOS任务内存布局时,在内核堆数组的高地址上方,
存在 tasks.c 的静态全局变量区域,这里存放着 FreeRTOS 用来管理任务状态的核心链表结构。
.map文件中可以看到如下图所示:

在内存模型中如下图所示:

这段区域就是 FreeRTOS 内核用来管理任务状态的核心数据结构的存放区域。
这一段源码,需要注意的关键点有两个:
第一,这些变量被定义为 static 修饰。
这意味着:
它们只在 tasks.c 文件内部可见,不允许外部文件直接访问。
也就是说:任务调度的核心数据结构,被严格封装在内核内部。
当然这也是可以预见的设计,毕竟这些数据结构被内核管理,不能够暴露给外界使用。
第二,这些静态变量都是 List_t 类型。
这实际上是一个结构体类型,用于表示存储一条链表。
类型定义如下:

源码中每一个该类型的静态成员,都代表着一条或多条链表,用来管理任务的状态。
任务不同状态的管理,使用不同的链表。
在命名时,从术语的角度,它们统一被命名为“XX列表”。
在命名时,从术语的角度,它们统一被命名为“XX列表”。
在命名时,从术语的角度,它们统一被命名为“XX列表”。
下面,我们具体来看一下,都存在哪些列表。
就绪列表数组
在 FreeRTOS 中,最重要的一种任务状态就是就绪态(Ready)。
那么,FreeRTOS内核如何管理就绪态任务呢?
实际上,FreeRTOS 使用了一个 List_t 类型的数组 来管理所有就绪态任务。
这一数据结构是 FreeRTOS 调度器中最核心的数据结构之一。
在源码中,对应的变量定义如下:
// 最前面的宏和static只是修饰符,不影响语义,在分析结构时可以暂时忽略它们
PRIVILEGED_DATA static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];
通常我们将它称为:
- 就绪队列,这种叫法符合操作系统课程中的概念,但不建议这么叫。
- 更准确的叫法应该是:就绪列表数组,在下面的课程中我们也会采用这种称呼。
所谓 就绪列表数组,本质上就是:
FreeRTOS 使用一个 List_t 类型的数组,来分别管理不同优先级下的就绪任务。
关于就绪列表数组的具体内部实现方式,以及它如何进行任务状态的管理,我们将在下面的小节中进行详细分析。
延时阻塞列表
如果某个任务因延时进入阻塞状态,例如调用了 vTaskDelay() 函数,
那么此时任务会发生一次状态转换:
Running → Blocked
也就是说,任务主动放弃 CPU 执行权,不再参与 CPU 竞争,进入阻塞状态。
在 FreeRTOS 内核中,所有“因时间而阻塞”的任务,都会被插入到一个专门的链表结构中进行管理。
对应源码中的关键变量如下:
PRIVILEGED_DATA static List_t xDelayedTaskList1;
PRIVILEGED_DATA static List_t xDelayedTaskList2;
PRIVILEGED_DATA static List_t * volatile pxDelayedTaskList;
PRIVILEGED_DATA static List_t * volatile pxOverflowDelayedTaskList;
通常我们将它们统称为:延时阻塞列表。
当然实际上延时阻塞列表有两个,xDelayedTaskList1和xDelayedTaskList2。
至于这些数据结构是如何完成任务的阻塞状态管理,以及为何需要两张延时阻塞列表配合使用,我们之后会详细分析。
挂起相关列表
如果某个任务被挂起,进入挂起态,那么该任务相当于被人为暂停,暂时不参与 CPU 执行权的竞争。
此时,任务会从原有状态列表中移除,并被插入到专门的挂起列表中进行管理。
在 FreeRTOS 内核中,与“挂起”相关的列表结构并不只有一个。
对应源码中的关键变量包括:
对应源码变量如下:
PRIVILEGED_DATA static List_t xSuspendedTaskList; // 挂起列表
PRIVILEGED_DATA static List_t xPendingReadyList; // 挂起就绪列表
其中:
xSuspendedTaskList用于存放真正处于挂起态的任务;xPendingReadyList则用于调度器暂停期间,暂存“待进入就绪态”的任务。
为了避免术语混淆,在后续课程内容中,我们将分别讲解:
- 挂起列表(Suspended Task List)
- 挂起就绪列表(Pending Ready List),也可以叫待就绪列表
挂起就绪列表涉及到下一章节的重要概念——临界区。
所以本章节中我们只讲一下挂起列表。
本小节先给出结构上的整体认知,具体的数据流转机制将在下面的小节中详细展开。
就绪列表数组(重点)
在FreeRTOS中,一个任务处于某个状态,实际上是通过它处于“哪个列表里”来进行判断的。
如果一个任务处于 就绪态(Ready) 或 运行态(Running),那么它实际上是处于“就绪列表数组”中的。
没有运行列表
首先需要特别强调一点:运行态并没有专属的列表。
因为:
- 运行态本质上只是“当前被选中的那个就绪任务”
- 它仍然存在于就绪列表中
- 只是调度器用一个当前TCB指针(pxCurrentTCB)指向了它
比如下列源码:
在tasks.c文件的第442行,存储了一个当前TCB指针:

这个指针,就用于标记当前处于运行态的任务。(因为CPU只有一个,所以同一时刻只可能有一个任务上CPU)
再比如下列源码:
如果创建多个优先级相同的任务,那么在任务创建完成后,当前TCB指针实际上会指向最后一个创建的任务。


了解了上述FreeRTOS内核源码,对于下面代码:
#include "stm32f10x.h" // STM32F10x 标准外设库头文件
#include "FreeRTOS.h" // FreeRTOS 核心头文件
#include "task.h" // FreeRTOS 任务相关 API
#include "DebugUSART1.h" // 调试串口模块(printf1)
// -------------------- 任务1 --------------------
void task1(void *arg) {
while (1) {
printf1("Task1 \n");
vTaskDelay(1000);
}
}
// -------------------- 任务2 --------------------
void task2(void *arg) {
while (1) {
printf1("Task2 \n");
vTaskDelay(1000);
}
}
int main(void) {
// 1. 初始化调试串口
DebugUSART1_Init();
// 2. 创建任务1
xTaskCreate(task1,
"Task1",
configMINIMAL_STACK_SIZE,
NULL,
tskIDLE_PRIORITY + 1,
NULL);
// 3. 创建任务2(与任务1优先级相同)
xTaskCreate(task2,
"Task2",
configMINIMAL_STACK_SIZE,
NULL,
tskIDLE_PRIORITY + 1,
NULL);
// 4. 启动 FreeRTOS 调度器
vTaskStartScheduler();
// 理论上不会运行到这里
while (1) {
}
}
哪一个任务会先执行呢?
当然是Task2会先执行,它会先打印结果。
因为当前TCB指针,在任务创建完成后指向了任务2。
就绪列表数组本身
就绪列表数组相关的源码如下:
PRIVILEGED_DATA static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];
这里有几个关键点需要理解:
pxReadyTasksLists被称之为 就绪列表数组。- 数组长度由
configMAX_PRIORITIES决定。 - 每一个数组元素,都是一个
List_t类型的链表,也就是一个就绪列表。
configMAX_PRIORITIES 是在 FreeRTOSConfig.h 中配置的最大优先级数量,默认情况下,其取值就是5。

这意味着:
- 系统中最多支持 5 个优先级
- 优先级编号为 0 ~ 4
对应到就绪列表数组中,可以理解成:
FreeRTOS 内核默认维护的,是一个“按优先级分层”的就绪列表数组。
具体结构如下:
- 优先级 0 → 1条就绪列表
- 优先级 1 → 1条就绪列表
- 优先级 2 → 1条就绪列表
- 优先级 3 → 1条就绪列表
- 优先级 4 → 1条就绪列表
FreeRTOS内核就绪队列中,每一个优先级的任务,都有一条就绪列表。
如下图所示:

通常这种设计,不同优先级任务之间天然分层,结构清晰,便于内核调度任务的执行。
任务列表List_t类型
List_t类型,我们起一个专门的名称:任务列表,简称列表。

任务列表是一个通用的链表类型,任务各个状态的列表都由此类型来表示。
那么一个“任务列表”的数据结构又是什么样的呢?
通过查阅源码我们知道,此类型中最核心的是三个成员:
- uxNumberOfItems
- pxIndex
- xListEnd
下面逐一进行解释。
成员:pxIndex
该成员的类型是ListItem_t *的指针类型。
ListItem_t类型,我们称之为:任务列表项,简称列表项。
任务列表项是任务列表的核心组成部分,用于表示链表中的一个结点,用来代表某个处于就绪/运行态的任务。
那成员pxIndex的作用是什么呢?
这个一个任务列表项指针类型成员,用于指向某个列表项。
该成员用于记录:当前列表,遍历到了哪个任务结点
当多个任务处于同一优先级时,FreeRTOS 通过移动 pxIndex 来实现时间片轮转。
简单理解就是:
- 上一次运行的是任务A
- 下一次调度,就从任务A后面的节点开始找
这就实现了“同优先级任务轮流执行”。
成员:xListEnd
该成员的类型是MiniListItem_t,这种类型,我们称之为:哨兵列表项。
这个成员是一个特殊的“哨兵节点”。
它并不代表具体的某一个任务,不存储有效数据。
它的作用是:
标记链表的结束位置
FreeRTOS 的任务列表,实际上一个“带哨兵结点的双向循环链表”。
xListEnd 是一个哨兵结点,它有两个作用:
- 作为链表的尾结点标记,始终指向链表的最后一个结点。
- 让链表操作逻辑更加统一,所有真实任务结点都必须插入在xListEnd 之前。
可以理解为:
这个一个永远存在于链表中的假节点,指示链表的尾部,方便进行插入结点的操作。
成员:uxNumberOfItems
成员的类型是UBaseType_t,这个类型我们太熟悉,它实际上就是一个无符号4字节整型。
再结合它的成员命名,这个成员的作用非常简单:
当前任务列表中,一共有多少个结点(也就是多少个任务)
这是一个计数器,用于记录链表中真实结点数量。
注意,哨兵结点不属于链表的真实结点,该计数器不会计算它。
在FreeRTOS内核调度时,调度器可以直接检查 uxNumberOfItems 是否为 0,从而判断某个优先级是否有任务。
如果该成员取值是0,说明该优先级没有就绪任务,那么就无需再去遍历链表了。
从而提升系统任务调度的效率。
任务列表项ListItem_t 类型
为了能够完整画出“就绪列表数组”的数据结构图,我们还需要深入研究一下任务列表项ListItem_t 类型。
List_t 只是一个存储链表结构的容器,真正的链式结构的结点类型是任务列表项ListItem_t 类型。
此类型的源码如下:

在FreeRTOS中为了简化代码,避免反复进行类型转换,所以:
- 任务列表List_t类型,是所有状态列表都共用的类型。
- 任务列表项ListItem_t 类型,所有状态的任务也共用同一个类型。
这就导致一个问题:某种状态的任务,其任务列表项的成员,并不是每一个成员都有作用。
比如处于就绪态的任务,只有以下成员有作用:
- pxNext
- pxPrevious
- pvOwner
- pvContainer
下面来逐一介绍这四个成员,也是最通用的四个成员,任务无论处于何种状态,都涉及这四个成员。
成员:pxNext和pxPrevious
这两个成员的类型都是任务列表项ListItem_t 的指针类型,它们的作用就非常明显了:
- pxNext指向链表的下一个任务列表项节点
- pxPrevious指向链表的上一个任务列表项节点
任务列表,这条链表中的每一个任务结点都会存储下一个和上一个结点的指针。
由此可见,任务列表是一条双向循环链表。
成员:pvOwner
该成员是任务调度过程中,调度器能够定位任务本体的核心桥梁。
pvOwner是一个指针类型,而且是一个指向任务TCB结构的指针。
也就是说:
每一个任务列表项结点本身并不存储任务信息,而是通过 pvOwner 指针,间接指向任务的 TCB。
因此可以这样理解,FreeRTOS 内核调度器在遍历就绪链表时:
- 先获取链表中的
ListItem_t - 再通过
pvOwner找到对应任务的 TCB - 最终通过 TCB 中保存的栈指针、优先级等信息完成任务切换
所以本质上:
调度器并不是直接在操作“任务函数”,而是在操作“链表节点”,再通过链表节点中的 pvOwner,间接定位到任务 TCB。
可以用一句话总结:
FreeRTOS 的调度过程,本质是“链表节点 → pvOwner → TCB → 栈指针”的层层定位过程。
这样设计的好处是:
链表结构与任务控制结构解耦,提高了内核结构的通用性和可扩展性。
成员:pvContainer
在任务列表项结构体类型中,成员pvContainer的类型是任务列表List_t类型。
所以这个成员的作用是:记录当前任务列表项属于哪个任务列表。
也就是说:
每一个 ListItem_t,都知道自己被挂在哪个 List_t 里。
这个成员有啥作用呢?
我们来设想一个场景。
假如某一个任务需要被删除,那么就需要从任务列表中移除它的列表项。
如果 ListItem_t 不知道自己属于哪一个列表,那么只能这么做:
- 遍历所有可能的任务列表
- 在每个任务列表中查找该任务列表项结点
- 找到之后再执行删除操作
这种方式不仅麻烦,而且效率低。
而有了 pvContainer 成员之后,情况就完全不同了。
删除流程变成:
- 通过
pvContainer直接获取所属的List_t - 直接移除对应的任务列表项
- 随即更新任务列表中的节点数量与指针关系
整个过程无需遍历其他列表。
如此就简化了操作,更提升了任务调度的性能。
哨兵列表项MiniListItem_t类型
为了能够画出就绪列表数组的数据结构,我们还需要深入了解一下哨兵列表项MiniListItem_t类型。
哨兵列表项类型非常简单,因为它并不存储有效任务数据。
其类型定义源码,如下图所示:

在每一个 List_t 任务列表中,都包含一个成员:
MiniListItem_t xListEnd;
这个成员,就是整条链表的“尾部标记”,不存储有效数据,只是一个哨兵结点。
特别需要留意的是:
哨兵结点直接存储在结构体中作为一个成员,而不是存储指针。
由此可见,任务列表的链表是一条双向循环链表,并且是一个带哨兵结点的双向循环链表。
这样设计的好处是:
- 插入和删除效率更高
- 不需要单独处理头尾特殊情况
某种程度上来说,是一种空间(存储更多结点指针)换时间(效率提升)的做法。
实践中使用链表,多数都会使用这种双向循环链表,用更多空间占用来换取效率的提升。
就绪任务数组的数据结构图
经过上面的一通讲解和描述,FreeRTOS内核的就绪任务数组的数据结构图,就基本可以很清晰的画出来了。
但还是有一些小细节需要补充一下:
- 任务列表中的哨兵结点和普通结点类型是不同的。
- 但实际上在FreeRTOS内核代码中,会直接把哨兵结点当成普通结点使用。
- 这是做的好处是,可以让哨兵结点和普通结点之间可以互相指向。
- 之所以可以这么做是因为哨兵结点和普通结点,它们前半部分(前三个成员)是完全一致。
- 这种结构体类型的“前缀兼容设计”,在各种操作系统内核中都很普遍,目的是为了节约空间,简化代码实现。
- 哨兵结点名为xListEnd,它的作用是标记双向链表的尾部,这一个细节需要注意。
- 在初始化任务列表时,pxIndex成员会初始化为指向哨兵结点,当然FreeRTOS并不会把哨兵结点当成真实任务去调度。
上述描述可以参考内核函数vListInitialise()的实现:

以上。
下面我们结合不同的情况,来画就绪任务列表的数据结构示意图。
不存在任务结点的就绪任务列表
如果某个优先级的就绪任务列表不存在任何真实任务结点,那么它的数据结构示意图。如下图所示:

哨兵结点的前驱与后继指针都指向自己。
pxIndex标记当前遍历结点,也指向哨兵结点。
一个任务结点的就绪任务列表
如果某个优先级的就绪任务列表,只存在一个任务结点。那么它的数据结构示意图。如下图所示:

解释一下这张图:
- 哨兵结点和普通结点互相指向,各自存储的两个指针都指向对方。
- pxIndex指向唯一的普通结点,如果所有处于就绪态的任务中,当前优先级是最高的,那么此任务就会上CPU,且不会进行时间片轮转。
- pvContainer指向当前任务列表。
多个任务结点的就绪任务列表
如果某个优先级的就绪任务列表,存在多个任务结点。那么它的数据结构示意图。如下图所示:

解释一下这张图:
- 每个任务列表项两个指针的指向都省略了,它们一个指向自身任务的TCB,一个指向当前任务列表。
- 严格来说,循环链表没有所谓的“头尾”,但图中还是把哨兵结点当成尾部,这么做的目的是为了区分前驱和后继的方向。
整体数据结构模型图
整体数据结构模型图,如下图所示:

任务的就绪列表数组,其结构搞清楚后,那么FreeRTOS内核,在“就绪态和运行态”之间的任务调度,是如何进行的呢
就绪任务调度机制
在FreeRTOS中,如何来决定当前哪一个任务上CPU呢?
很简单:
- FreeRTOS 的任务调度,就是在就绪列表数组中,找到最高优先级的非空链表,然后从该链表中选出一个任务执行。
- 如果最高优先级的任务具有多个,那么采用时间片轮转机制。
这个过程需要解决的第一个问题就是:
如何找到最高优先级的任务列表?
找到最高优先级的任务列表
最简单办法是从任务列表数组的末尾开始遍历数组元素,直到找到一条不为空的链表。
但这个操作涉及遍历,从性能上还是有点差。
在开启下列宏的前提下(实际上默认开启):
#define configUSE_PORT_OPTIMISED_TASK_SELECTION 1
内核维护一个关键变量:

简单来说,通过这个静态全局变量,内核可以一步到位不需要遍历就知道当前最高优先级的任务列表。
那这是怎么实现的呢?
uxTopReadyPriority的类型是UBaseType_t,通常就是一个4字节无符号整型。
但只有其中的低 configMAX_PRIORITIES 位有效,通常就是低 5 位使用,高27则属于保留位。
假如系统当前存在:
- 优先级4 的就绪任务
- 优先级0 的就绪任务
那么:
uxTopReadyPriority = (1 << 4) | (1 << 0) = 0b10001 = 17
调度器在任务调度时,只要找到uxTopReadyPriority最高位的1:
比如找到上述数据最高位1在bit4,调度器就知道当前最高优先级的任务列表是优先级4。
扩展1:
uxTopReadyPriority变量实际上是一个“优先级位图”。
所谓位图(BitMap),是一种用整数的“每一位(二进制位)”来表示某种状态的数据结构。
不要把它和数据结构中的图(Graph)混淆。
位图中的“图”,来源于英文 Bitmap 中的 Map,表示映射关系。
从真实含义上来说,其实把 Bitmap 翻译为“位映射”更加贴切。
所谓优先级位图,在 FreeRTOS 中,就是用一个整数变量的每一位,来表示对应优先级的就绪列表是否存在就绪任务。
比如上面提到的案例:
优先级 4和0 存在处于就绪态的任务,那么uxTopReadyPriority 这个变量,其取值就是0b10001。
这种“用 bit 表示状态集合”的位图设计,以极小的内存占用,基于位运算的高性能快速定位状态,在操作系统中被广泛采用。
扩展2:
FreeRTOS默认采用这种“优先级位图”的设计,但 uxTopReadyPriority 默认只是一个4字节无符号整型,只有32位。
这其实隐含了一个对优先级最大值的限制:
优先级的等级不应该超过32,即支持的最大优先级为31,即宏configMAX_PRIORITIES的值应该小于等于32。
一旦优先级最大值超过31,优先级位图就无法覆盖全部优先级,其行为就会变为未定义不可预测。
当然绝大多数嵌入式系统,也没必要搞这么多任务优先级,多数情况5个优先级等级已经完全足够了。
任务创建时如何插入就绪列表
首先,就绪列表List_t类型的结构体对象,其成员pxIndex,用于标记当前就绪列表,遍历到了哪个任务结点。
在任务列表初始化时,pxIndex指针指向了哨兵结点。
在整个创建任务的过程中,pxIndex指针并不会被移动,仍然指向哨兵结点。
想象一个场景:
依次序创建Task123三个任务,它们的优先级是一致的,会进入同一个就绪列表。
那么这三个任务的任务列表项结点,加上哨兵结点,是如何组成一条链表的呢?
为了解决这个问题,我们就需要查看相关源码了:

如果你不相信,还可以更进一步点进去看看这个宏函数:

也就是说:
依次序创建优先级一致的Task123三个任务,就绪列表如下:
哨兵 → Task1→ Task2 → Task3 → 哨兵
就绪列表时间片轮转
现在这三个任务处于同一个就绪列表,它们的优先级是一致的,所以采用时间片轮转。
这个过程主要涉及两个核心的变量:
- pxIndex:标记目前已遍历的任务结点,一开始指向哨兵结点。
- pxCurrentTCB:当前TCB指针,它指向真正上CPU,处于运行态的任务TCB。
接下来,调度器在每一次需要选择任务时,都会执行如下逻辑:
- 找到当前最高优先级的就绪列表(本例中最高优先级是1)。
- 在该就绪列表中,通过 pxIndex 向后移动一个结点,将移动到的结点对应的 TCB 赋值给 pxCurrentTCB。
- 随即完成任务的上下文切换,实现时间片轮转机制。
进行任务上下文切换的函数是:
static void vTaskSwitchContext( void )
一路向内部寻找核心代码,最终找到源码如下:

由于pxCurrentTCB在任务完成创建后,已经指向了“Task3”,所以实际上在调度器启动后,这三个任务的执行顺序是:
Task3 → Task1 → Task2 → Task3 → Task1 → Task2 ......
其中Task3会第一个上CPU是因为,任务创建后当前TCB指针指向了Task3
随后执行Task1,这是因为Task1是哨兵结点的下一个结点,pxIndex 向后移动一个结点就是它。
通过Keil5的调试模式查看就绪列表
将上述案例的代码放入Keil5中编译烧录:
#include "stm32f10x.h" // STM32F10x 标准外设库头文件
#include "FreeRTOS.h" // FreeRTOS 核心头文件
#include "task.h" // FreeRTOS 任务相关 API
#include "DebugUSART1.h" // 调试串口模块(printf1)
// -------------------- 任务1 --------------------
void task1(void *arg) {
TickType_t xLast = xTaskGetTickCount();
while (1) {
printf1("Task1 \n");
}
}
// -------------------- 任务2 --------------------
void task2(void *arg) {
TickType_t xLast = xTaskGetTickCount();
while (1) {
printf1("Task2 \n");
}
}
// -------------------- 任务3 --------------------
void task3(void *arg) {
while (1) {
printf1("Task3 \n");
}
}
int main(void) {
// 1. 初始化调试串口
DebugUSART1_Init();
// 2. 创建任务1
xTaskCreate(task1,
"Task1",
configMINIMAL_STACK_SIZE,
NULL,
tskIDLE_PRIORITY + 1,
NULL);
// 3. 创建任务2(与任务1优先级相同)
xTaskCreate(task2,
"Task2",
configMINIMAL_STACK_SIZE,
NULL,
tskIDLE_PRIORITY + 1,
NULL);
// 4. 创建任务3(与任务1优先级相同)
xTaskCreate(task3,
"Task3",
configMINIMAL_STACK_SIZE,
NULL,
tskIDLE_PRIORITY + 1,
NULL);
// 5. 启动 FreeRTOS 调度器
vTaskStartScheduler();
// 理论上不会运行到这里
while (1) {
}
}
然后进入调试模式,打断点:

随后可以打开Keil5调试模式的监视窗口,输入表达式,查看就绪列表:

这里使用了下列表达式来查看优先级为1的任务列表:
pxReadyTasksLists[1].uxNumberOfItems // 查看优先级为1的任务列表有几个任务,目前是3个
((TCB_t *)pxReadyTasksLists[1].xListEnd.pxNext->pvOwner)->pcTaskName // 哨兵节点的后继结点任务,Task1
((TCB_t *)pxReadyTasksLists[1].xListEnd.pxNext->pxNext->pvOwner)->pcTaskName // Task1任务的后继结点任务,Task2
// Task2任务的后继结点任务,Task3
((TCB_t *)pxReadyTasksLists[1].xListEnd.pxNext->pxNext->pxNext->pvOwner)->pcTaskName
// 当前TCB指针,任务调度器启动后,Task3会最先上CPU
pxCurrentTCB->pcTaskName
这张图足以完美解释上面的推论。这里不再赘述了,如果你还有些不理解,可以仔细自行分析一下。
延时阻塞列表
理解了就绪列表数组,就绪态和运行态的切换,就已经掌握了 FreeRTOS 调度器的核心机制。
接下来我们继续看一种任务列表——延时阻塞列表。
在内核源码task.c中,可以看到这样几行变量定义:
PRIVILEGED_DATA static List_t xDelayedTaskList1;
PRIVILEGED_DATA static List_t xDelayedTaskList2;
PRIVILEGED_DATA static List_t * volatile pxDelayedTaskList;
PRIVILEGED_DATA static List_t * volatile pxOverflowDelayedTaskList;
它们就是FreeRTOS内核,延时阻塞状态管理的核心数据结构。
在 FreeRTOS 中,某个处于运行态的任务一旦调用vTaskDelay()等时间相关的延时函数后:
其任务列表项就会从就绪列表移除,转而被插入延时阻塞列表当中。
延时阻塞队列为什么有两个呢?
为什么还需要使用两个指针呢?
下面来详细介绍一下FreeRTOS延时阻塞状态的管理机制。
xTickCount全局Tick计数器
所有的延时阻塞都会有“醒来”的时刻。
FreeRTOS所有的延时阻塞函数,都是基于这样的思想来实现:
如果需要延时x时间,那就让任务在进入阻塞状态后的“当前时间 + x时间”后回到就绪态。
那么问题来了,在FreeRTOS中,怎么知道当前时间呢?
在内核源码文件tasks.c中,维护了这样的一个全局系统节拍计数器:

在系统启动时,从0开始计数,每过一个系统节拍就自增1。
这个计数器就可以表示FreeRTOS内核的全局时间。
那么一个系统节拍是多长时间呢?
这个我们早就知道了,在配置文件中配置了下面的宏:
#define configTICK_RATE_HZ ( ( TickType_t ) 1000 )
这就是说:1秒钟分为1000个系统节拍,1个系统节拍就是1ms。
在内核源码文件tasks.c中,还存在这样的一个函数xTaskIncrementTick(),它会在内核每过一个系统节拍时,为此全局变量自增1:

当前这里面的注释也提到了:
xTickCount的类型是TickType_t(无符号),通常就是一个无符号4字节整型数据。xTickCount每个 系统节拍 自增 1,持续累加必然发生溢出。- 全局系统节拍计数器的最大取值是
4294967295,约等于每计时49.71天就会产生溢出。 - FreeRTOS把这个现象称之为“系统节拍回绕”,也就是全局系统节拍计数归0。
xTaskGetTickCount()函数,其函数原型如下:
TickType_t xTaskGetTickCount( void );
该函数的返回值,就是当前系统全局 Tick 的计数值。
如果你想获取当前系统 Tick,可以直接调用此函数。
任务列表项中的成员:xItemValue
在就绪列表中,某个任务列表项的成员xItemValue通常是没有意义的。
但是在延时阻塞列表中,该成员是一个起着核心作用的成员:
它会存储当前任务“苏醒”的全局系统节拍计数,也就是记录当前任务结束延时阻塞,回归就绪态的时间。
可以通过查阅vTaskDelay()等函数的源码,找到prvAddCurrentTaskToDelayedList()函数,来了解这一个设定:


既然每一个处于延时阻塞列表的任务结点,都记录了自己的苏醒时间,那么接下来就好办了:
只需要把结点按照苏醒时间排序,早苏醒的(节拍计数小的)排在前面,晚苏醒的排在后面。
由于延时阻塞列表是按唤醒时间升序排列的,调度器就无需遍历整条链表了。
而是仅需要检查头部的结点,只要头部的结点不苏醒,剩下的结点任务必然会持续阻塞。
两个延时阻塞列表的作用
了解了前面的知识点,那么就可以解释一个问题了:
为什么延时阻塞列表有两个。
这个问题的答案,并不复杂,核心原因只有一个:
系统的全局 Tick 计数器是会“回绕”的。
现在思考一个场景。
假设当前:xTickCount = 0xFFFFFFF0
某个任务A调用:
vTaskDelay( 50 );
那么它的唤醒时间为:
xTimeToWake = 当前 Tick + 延时 Tick
= 0xFFFFFFF0 + 50
= 发生溢出 → 0x00000022
这个唤醒时间节拍计数,其数值由于溢出变得很小。
另一个任务B调用:
vTaskDelay( 5 );
那么它的唤醒时间为:
xTimeToWake = 当前 Tick + 延时 Tick
= 0xFFFFFFF0 + 5
= 0xFFFFFFF5
这个唤醒时间节拍计数,由于没有溢出,所以是一个比较大的数值。
问题来了。
如果我们只有一个延时链表,并且简单按“唤醒时间升序排序”。
那么这两个任务应当先唤醒谁呢?
从时间顺序上看:B 应当先唤醒
但实际上,如果只有一条延时阻塞列表,那么A任务由于唤醒时间小,处在链表前面,它会被先唤醒。
这显然是不正确的,不合理的。
为了解决这个问题,FreeRTOS的设计者将延时阻塞列表分为了两类,对应两类阻塞任务:
第一类延时阻塞列表,存放苏醒时间在溢出前的任务结点,这就是“当前延时阻塞列表”。
也就是指针pxDelayedTaskList指向的延时阻塞列表。
第二类延时阻塞列表,存放苏醒时间在溢出后的任务结点,这就是“溢出/回绕延时阻塞列表”。
也就是指针pxOverflowDelayedTaskList指向的延时阻塞列表。
也就是说:
PRIVILEGED_DATA static List_t xDelayedTaskList1;
PRIVILEGED_DATA static List_t xDelayedTaskList2;
这两个延时阻塞列表,它们究竟是“当前延时”还是“回绕延时”,要看两个指针哪一个指向它们:
- 如果pxDelayedTaskList指针指向了xDelayedTaskList1,那么xDelayedTaskList1就是当前延时阻塞列表。
- 如果pxOverflowDelayedTaskList指针指向了xDelayedTaskList1,那么xDelayedTaskList1就是回绕延时阻塞列表。
回绕后延时阻塞列表的处理
xDelayedTaskList1和xDelayedTaskList2谁是当前延时阻塞列表,谁是回绕延时阻塞列表?
这当然不是固定的,会进行轮流的切换。
在内核第一次创建任务时,就会初始化内核任务列表的数据结构,涉及一个函数prvInitialiseTaskLists()。
其源码如下图所示:

可以看到,在系统启动后,一开始:
- pxDelayedTaskList指针指向了xDelayedTaskList1,xDelayedTaskList1是当前延时阻塞列表。
- pxOverflowDelayedTaskList指针指向了xDelayedTaskList2,xDelayedTaskList2是回绕延时阻塞列表。
还是回到上面的场景中:
- 当前的系统节拍计数是:xTickCount = 0xFFFFFFF0
- 任务A的xItemValue成员中存储的数据是:0x00000022
- 这个唤醒时间数值显然小于当前系统节拍计数,所以它是一个回绕后的时间
- 于是任务A进入延时阻塞状态后,会进入回绕延时阻塞列表。
- 也就是xDelayedTaskList2列表,pxOverflowDelayedTaskList指向这个列表。
- 任务B的xItemValue成员中存储的数据是:0xFFFFFFF5
- 这个唤醒时间数值显然大于当前系统节拍计数,所以它不是一个回绕后的时间
- 于是任务B进入延时阻塞状态后,会进入当前延时阻塞列表。
- 也就是xDelayedTaskList1列表,pxDelayedTaskList指向这个列表。
随后系统中的流程是这样的:
- 当全局系统节拍计数达到0xFFFFFFF5时,任务B会结束阻塞苏醒,回归就绪态,从xDelayedTaskList1列表移除。
- 当全局系统节拍计数达到0xFFFFFFFF且继续进入下一个节拍时:
- 此时全局系统节拍计数溢出回绕,xTickCount = 0。
- pxOverflowDelayedTaskList和pxDelayedTaskList两个指针需要交换指向的对象。
- 随后xDelayedTaskList2列表变成了当前延时阻塞列表,xDelayedTaskList1列表变成了回绕延时阻塞列表。
- 当全局系统节拍计数达到0x00000022时,任务A会结束阻塞苏醒,回归就绪态,从xDelayedTaskList2列表移除。
- .....
xDelayedTaskList1 和 xDelayedTaskList2 这两个链表,会 交替作为“当前延时列表”和“回绕延时列表” 使用。
从而共同完成 FreeRTOS 中 延时阻塞任务的管理与调度。
在系统运行过程中:
pxDelayedTaskList指针指向 当前延时列表pxOverflowDelayedTaskList指针指向 回绕延时列表
当系统全局 Tick 计数器发生 回绕时,这两个指针会进行一次 交换。
交换之后:
- 原来的回绕延时列表变成新的当前延时列表
- 原来的当前延时列表则变成新的回绕延时列表
这样一来,FreeRTOS 就可以继续按照新的时间周期来管理延时任务。
pxDelayedTaskList和pxOverflowDelayedTaskList这两个指针交换的源码如下图所示:


以上。
这套设计的精妙之处在于:
- 利用无符号 Tick 计数器的自然回绕特性
- 将时间范围划分为两个周期区间
- 一个周期对应当前延时列表
- 另一个周期对应回绕延时列表。
- 通过交换两个指针完成时间周期切换,不需要重新计算或调整列表结构。
- 整个过程中无需移动任何列表节点。
因此,从实现思想上来看,这种设计本质上是:
通过增加一个链表结构,将复杂的时间判断问题转化为简单的指针交换操作。
也可以理解为:
用结构设计降低算法复杂度。
总之,FreeRTOS 的延时机制,并不是简单“等待多少毫秒”,而是一个基于 Tick 计数、链表管理和回绕处理的完整时间调度系统。
关于这一点,关于FreeRTOS延时函数的具体原理,我们等到后面讲定时器时再详谈。
FreeRTOS提供的延时阻塞函数
前面我们已经讲完了 延时阻塞列表 的实现机制。大家已经知道:FreeRTOS系统中存在一个 全局 Tick 计数器 xTickCount。
任务调用延时函数之后,会发生这样几件事情:
- 当前任务从 就绪列表 中移除
- 任务被插入 延时阻塞列表
- 当全局 Tick 计数器
xTickCount增长到任务设定的唤醒时间(即xItemValue) - 任务就会重新进入 就绪列表,回到就绪态
也就是说:
FreeRTOS 的延时,本质上是通过延时阻塞列表 + 全局 Tick 计数实现的。
知道了这个本质,那么我们就可以来一起看一下FreeRTOS 内核为我们提供的 任务级延时函数。
在实际编程中,最常用的用于延时,使任务进入阻塞态的函数有两个:
vTaskDelay()
vTaskDelayUntil()
这两个函数基本覆盖了 FreeRTOS 中绝大多数的延时场景。
下面我们就分别来看一下,这两个函数是怎么用的,以及它们之间有什么区别。
当然,首先我们要知道的是:
这两个延时函数,能够使得任务进入阻塞态,主动放弃CPU!
而且这两个函数都是默认配置启用的,在FreeRTOS配置文件中存在下列宏:
#define INCLUDE_vTaskDelayUntil 1
#define INCLUDE_vTaskDelay 1
vTaskDelay()函数
vTaskDelay() 的函数原型是:
void vTaskDelay( const TickType_t xTicksToDelay );
这个函数只有一个参数:xTicksToDelay
用于表示当前任务希望延时多少个 系统节拍 Tick。
vTaskDelay()函数的实现逻辑,和上面讲的延时阻塞列表的原理是完全一致的:
该内部函数会完成几个操作:
- 当前任务从 就绪列表 中移除
- 计算任务的 唤醒时间 = 此函数调用时的全局Tick + xTicksToDelay
- 将任务插入 延时阻塞列表
当系统 Tick 递增到这个唤醒时间时,任务就会从阻塞态“苏醒”,重新回到 就绪列表。
也就是说:
vTaskDelay() 的本质,就是让任务进入延时阻塞列表等待 Tick。
举一个例子:
vTaskDelay(1000);
在默认配置的情况下,1个系统节拍Tick是1ms,所以这种写法就表示:
任务大概延时1000ms,也就是大概1s后重新回到就绪态。
为什么是大概呢?
要回答这个问题,我们可以先思考一个更简单的问题:
vTaskDelay(1);
是不是精确延时 1 个系统节拍?
答:大概率不是。
为什么呢?
原因在于vTaskDelay(1) 的唤醒时间是这样计算的:
xTimeToWake = xTickCount + xTicksToDelay
如果调用:
vTaskDelay(1)
则:
xTimeToWake = 全局Tick + 1
任务会进入延时列表,等待 Tick 递增到这个时间。
举一个具体数值的例子:
假如,当前 Tick = 1000
任务调用:vTaskDelay(1)
则内核计算出的唤醒时间为:1001
任务会被插入延时阻塞列表,等待系统 Tick 增长到 1001。
问题在于:
任务是在 Tick 周期的哪个时刻调用 vTaskDelay(1)。
如果任务是在1000这个 Tick 刚刚开始的时候调用,那么:
下一次 Tick (1001)到来大约还有完整的一个 Tick 周期。
因此vTaskDelay(1)延时接近:1 Tick,接近1ms
但如果任务是在 Tick=1000 即将结束的时候 调用 vTaskDelay(1)
那么:
可能很短暂的几十微秒之后,1000这个Tick就结束了,1001这个Tick就会到来。
于是任务的唤醒条件就很快得到满足,任务会重新进入就绪列表。
这种情况下,实际延时的时长很短暂,可能只有:几十微秒
所以可以得到一个重要结论:
vTaskDelay(1) 的实际延时时长是一个范围:0 ~ 1 Tick,而不是固定的 1 Tick。
我们把vTaskDelay()函数实现的延时,称之为“相对延时”,因为它始终相对于函数执行的当前Tick来完成延时。
当然,如果延时的 Tick 数 大于 1,情况就会不同。
因为从第二个延时 Tick 开始,整个 Tick 都在延时内度过。
所以vTaskDelay()的误差最大是接近1 Tick,这个误差不会随延时时长变长而增加。
再回到:vTaskDelay(1000);
这个函数的实际延时时长是:999Tick ~ 1000Tick
vTaskDelay()函数实现的相对延时,实现简单,使用起来也比较简单,但确实存在1个Tick内的误差。
在实际工程中,如果不要求特别高的精度,把这个函数当成延时 1s 其实问题也不大。
但如果特别在意精度,就需要使用另一个函数——vTaskDelayUntil(),下面来讲一下这个函数。
vTaskDelayUntil()函数
vTaskDelayUntil() 的函数原型如下:
void vTaskDelayUntil( TickType_t * const pxPreviousWakeTime,
const TickType_t xTimeIncrement );
如果说vTaskDelay()实现的是相对延时,那么vTaskDelayUntil()就是实现了绝对延时。
为了讲清楚这个函数,就需要先了解一个问题——vTaskDelay() 的时间漂移问题。
vTaskDelay() 的周期漂移问题
假设我们希望任务 每 1 秒执行一次。
很多人会这样写:
while(1){
Operate();
vTaskDelay(1000);
}
看起来好像是:每执行一次任务,就延时 1000 Tick。
看起来好像是:每次执行任务间隔 1000 Tick。
但这里有一个隐藏的问题:
任务本身也是要消耗时间的。
例如:Operate()函数的调用,任务自身的操作,需要执行 5 个Tick
那么任务的一次完整执行时间,实际上是:
任务执行时间 + vTaskDelay时间
也就是说:
任务的实际周期 = 任务执行时间 + 1000 Tick
于是任务周期实际上变成了 :
1005 Tick
而且任务操作执行所消耗的时间Tick,大概率不会每次都是一样的。
再加上vTaskDelay()本身就有1Tick的误差。
而且随着循环次数增加,误差还会不断累积。最终任务的执行,就会越来越偏离原来的周期。
这就叫做:
周期漂移(Period Drift)问题
比如:
你想设置一个周期为1000Tick的周期任务,使用vTaskDelay(),它的执行时间线由于周期漂移就会越来越奇怪:
第1次执行Tick:0
第2次执行Tick:1005
第3次执行Tick:2010
第4次执行Tick:3015
...
在实际的工程环境下,如果某一个任务特别在意周期性执行,特别需要确保它的执行周期不变
那么使用vTaskDelay()进行延时显然是错误的,此时就需要使用vTaskDelayUntil()函数。
vTaskDelayUntil() 的工作方式
这个函数有两个参数,它们的含义分别是:
- pxPreviousWakeTime,用于记录 上一次任务唤醒的时间,需要传参一个指针类型,因为函数内部会修改你传参的唤醒时间。
- xTimeIncrement,用于指定 任务运行的周期(单位:Tick)
此函数的典型调用方式如下:
TickType_t LastWakeTime = xTaskGetTickCount(); // 获取当前系统全局Tick
while(1){
Operate();
vTaskDelayUntil(&LastWakeTime, 1000);
}
我们直接用Tick数值,来描述此时任务的执行过程:
任务被唤醒开始上CPU执行时,当前全局Tick = 1000
任务自身操作的执行,花费了 5 Tick
vTaskDelayUntil()依照记录的全局Tick 1000进行绝对延时 1000个Tick
于是任务延时到 Tick = 2000
...
任务被唤醒开始上CPU执行时,当前全局Tick = 2000
任务自身操作的执行,花费了 6 Tick
vTaskDelayUntil()依照记录的全局Tick 2000进行绝对延时 1000个Tick
于是任务延时到 Tick = 3000
...
即使任务本身操作的执行,消耗了几 Tick,甚至每一次执行消耗的Tick数量都不同
vTaskDelayUntil() 仍然会保证:任务总是按固定周期运行。
总结与建议
vTaskDelay() 的特点:
- 相对延时
- 延时时间相对于系统当前全局 Tick
- 可能产生周期漂移
- 适合工程中的简单延时,不在意精度的场景。
vTaskDelayUntil() 的特点:
- 绝对延时
- 按固定时间点唤醒任务
- 周期更稳定
- 适合周期任务
了解:pdMS_TO_TICKS()宏函数
在 FreeRTOS 中,经常会看到这样的写法:
vTaskDelay(pdMS_TO_TICKS(1000));
其中 pdMS_TO_TICKS() 实际上是一个宏函数,在源码 projdefs.h 文件中可以看到它的定义:
#ifndef pdMS_TO_TICKS
#define pdMS_TO_TICKS( xTimeInMs ) ( ( TickType_t ) ( ( ( uint64_t ) ( xTimeInMs ) * ( uint64_t ) configTICK_RATE_HZ ) / ( uint64_t ) 1000U ) )
#endif
这个宏函数的作用很简单:把毫秒值转换为 Tick 数值。
换算公式就是:
Tick = 毫秒值 × configTICK_RATE_HZ / 1000
如果配置宏configTICK_RATE_HZ的数值就是默认推荐值1000,那么这个宏函数实际没啥意义。
所以:
这个宏函数主要在configTICK_RATE_HZ不是1000时,才用得到,可以更方面的计算出毫秒值对应多少Tick。
但实际上,大多数FreeRTOS工程都会不会修改configTICK_RATE_HZ,所以这个宏函数作用有限,了解即可。
任务挂起状态和挂起列表
在 FreeRTOS 中,任务除了“就绪态”“运行态”“阻塞态”之外,还有一种特殊状态 —— 挂起态(Suspended)。

关于挂起态,我们需要了解以下几点基本概念:
- 处于任意调度状态的任务,都可以被
vTaskSuspend()挂起,进入挂起态。(甚至可以挂起后再挂起,但通常没有意义) - 被挂起的任务不参与任务调度,也不会因为条件满足而自行回归就绪态。
- 进入挂起态的任务,实质上是被移出原有列表,并插入内核维护的挂起列表xSuspendedTaskList。
- 在其他任务中调用 vTaskResume(),就可以恢复某个任务,让它重新被插入就绪列表,回到就绪态。
为什么设计挂起态
既然已经有阻塞态了,为什么还要挂起态?
阻塞态,通常表示任务因为“等待某种条件”而暂时不运行。
挂起态,则表示任务被人为强制停止运行。
阻塞态的恢复依赖条件满足,比如时间到了。
挂起态的恢复不依赖这些条件,而是依赖其他任务或中断主动调用恢复函数。
所以,阻塞更像“任务自己在等事情发生,然后自动恢复”,挂起更像“任务被别人按下了暂停键,不会自动恢复”。
vTaskSuspend()和vTaskResume()
想要使用这两个函数,必须开启下面这个宏:
#define INCLUDE_vTaskSuspend 1
当然,在配置文件中,它是一个默认配置:

默认开启任务的挂起状态,及其相关的API。
vTaskSuspend()函数的函数原型是:
void vTaskSuspend(TaskHandle_t xTaskToSuspend);
其中xTaskToSuspend,表示要挂起的任务句柄,如果传参NULL表示挂起当前运行的任务,也就是自己挂起自己。
vTaskResume()函数的函数原型是:
void vTaskResume(TaskHandle_t xTaskToResume)
其中xTaskToResume,表示要恢复挂起的任务句柄。
这里就不能再传参NULL了,被挂起的任务没有机会执行,不可能自己恢复挂起自己。
参考一个演示代码案例:
#include "stm32f10x.h"
#include "freertos.h"
#include "task.h"
#include "DebugUSART1.h"
#include <string.h>
/* ================================
* 任务1:
* 每1秒运行一次
* 第一次运行时主动挂起自身
* ================================ */
void task1(void * arg) {
int flag = 0; // 标记是否已自挂起
while (1) {
vTaskDelay(1000); // 延时1秒(阻塞态)
// 第一次运行时主动挂起自身
if (flag == 0) {
vTaskSuspend(NULL); // NULL表示挂起当前任务
flag = 1;
}
printf1("task1 \n");
}
}
/* ================================
* 任务2:
* 每1秒打印一次
* 始终处于正常调度状态
* ================================ */
void task2(void * arg) {
while (1) {
vTaskDelay(1000); // 延时1秒
printf1("task2 \n");
}
}
/* ================================
* beginTask:
* 负责控制 task1 的挂起与恢复
* ================================ */
void beginTask(void *arg) {
TaskHandle_t hand1, hand2;
// 创建两个优先级相同的任务(优先级 4)
xTaskCreate(task1, "Task1", configMINIMAL_STACK_SIZE, NULL, 4, &hand1);
xTaskCreate(task2, "Task2", configMINIMAL_STACK_SIZE, NULL, 4, &hand2);
while (1) {
// 延时5秒钟后,恢复挂起task1,回到就绪态
vTaskDelay(5000);
vTaskResume(hand1);
// 延时5秒钟后,挂起task1
vTaskDelay(5000);
vTaskSuspend(hand1);
}
}
/* ================================
* 主函数:
* 创建 beginTask 并启动调度器
* ================================ */
int main(void) {
DebugUSART1_Init(); // 初始化串口
// 创建 beginTask,优先级 3
xTaskCreate(beginTask, "beginTask",
configMINIMAL_STACK_SIZE,
NULL,
3,
NULL);
vTaskStartScheduler(); // 启动调度器
while (1) {
}
}
系统中一共存在三个任务:
- task1(优先级 4)
- task2(优先级 4)
- beginTask(优先级 3)
Task1 的行为分析:
task1 在第一次运行时,会主动调用:
vTaskSuspend(NULL);
将自己挂起。
这里有一个非常关键的点:
这是任务“主动把自己从调度系统中移除”。
执行该函数后,会发生以下事情:
- 任务从 就绪列表 中移除
- 被插入 挂起列表(Suspended List)
- 不再参与任何调度
也就是说:
一旦进入挂起态,这个任务就从调度器关注的状态列表中移出,不再参与 CPU 竞争。
想要让它重新运行,必须调用:
vTaskResume(hand1);
由外部将其恢复到就绪态。
beginTask 的控制逻辑分析:
beginTask 的作用是对 task1 进行“启停控制”。
其行为可以描述为:
- 每隔 5 秒恢复 task1
- 再隔 5 秒挂起 task1
形成一个周期性的控制流程:
恢复 → 运行 → 挂起 → 停止 → 再恢复
因此现象非常直观:
- task1 会周期性“出现 / 消失”
- 挂起期间完全没有任何打印或执行行为
可以把它理解为:
beginTask 在动态“开关” task1。
调度行为分析:
当前系统优先级关系如下:
- task1 / task2:优先级 4(高优先级)
- beginTask:优先级 3(低优先级)
调度过程可以分三种情况理解:
- 当 task1 和 task2 都处于就绪态,两个任务优先级相同
- 启用时间片轮转
- 表现为:task1 和 task2 交替运行
- 当 task1 被挂起,此时优先级 4 只剩 task2,表现为:task2 独占 CPU
- 当 task2 调用 vTaskDelay() 进入阻塞态,高优先级任务全部阻塞
- 此时调度器才会切换到 beginTask
- 表现为:低优先级任务只有在高优先级“让出CPU”时才能运行
最终结论:
挂起态,本质是一种:“人为控制的冻结状态”
它与阻塞态有本质区别:
- 阻塞态,由系统条件触发恢复(时间 / 事件)
- 挂起态,必须由外部手动恢复(vTaskResume)
可以用一句话总结:
阻塞是“等条件”,挂起是“被人为停掉”。
挂起列表
在 tasks.c 中可以找到如下定义:
PRIVILEGED_DATA static List_t xSuspendedTaskList;
这说明:
FreeRTOS 内核内部维护了一个挂起列表,用于存放处于挂起态的任务。
它的数据类型是:
List_t
也就是 FreeRTOS 内部的双向循环链表结构。
调用vTaskSuspend函数后,挂起操作的源码路径大体上是
/* 1. 从原任务列表移除任务 */
uxListRemove( &( pxTCB->xStateListItem ) );
/* 2. 插入挂起列表 */
vListInsertEnd( &xSuspendedTaskList, &( pxTCB->xStateListItem ) );
这两步非常关键:
- 第一步:把任务从原有状态列表中摘除
- 第二步:插入挂起列表
这意味着:
挂起态任务一定只存在于 xSuspendedTaskList 中。
任务处于任何状态都可以进行挂起,那么挂起时如果任务原本在阻塞态怎么办?
这是很多人容易误解的地方。
任务如果原本处于阻塞态,调用 vTaskSuspend() 时:
- 内核还是会把任务从阻塞列表移除
- 然后再插入挂起列表xSuspendedTaskList
并且在调用恢复挂起函数后,直接让任务去到就绪态。
所以:
- 如果挂起前任务处于阻塞状态,那么挂起操作会直接打破它的“阻塞条件”,直接进入挂起态。
- 恢复挂起时,直接进入就绪态。
而FreeRTOS内核调度器只会在就绪列表pxReadyTasksLists中寻找优先级最高的任务,进入运行态。
某个任务一旦进行挂起列表xSuspendedTaskList,那么调度器就不会管这个任务了。
因此,挂起列表是“调度系统之外”的一个隔离区。
它的设计意义是:
- 提供人为冻结任务的能力;
- 不参与优先级竞争;
- 不参与时间推进;
- 不参与事件唤醒;
任务删除
FreeRTOS 中任务的退出方式问题
在 FreeRTOS 中,一个任务在执行过程中,如果涉及“退出”,必须遵循内核规定的方式。
任务的退出方式主要有以下几种情况。
第一:不允许通过 return 退出任务
在 Linux 的进程或线程模型中,可以通过在入口函数中执行 return 来结束线程。
但是在 FreeRTOS 中:
- 任务不允许以任何方式从入口函数中 return;
- 也不允许执行到入口函数末尾自然结束。
这是因为:
FreeRTOS 任务的运行环境依赖于内核调度器管理,一旦入口函数返回,将导致栈和调度状态不可控。
因此:
任务函数必须始终处于一个无限循环中,或者通过 API 显式删除自身。
第二:可以通过 vTaskDelete 删除任务
FreeRTOS 提供了任务删除接口:
void vTaskDelete(
TaskHandle_t xTaskToDelete
);
使用该函数前,必须在 FreeRTOSConfig.h 中启用:
#define INCLUDE_vTaskDelete 1
参数说明:
- 传入具体任务句柄 → 删除指定任务;
- 传入 NULL → 删除当前任务(自删除)。
第三:任务删除后的内核行为
当调用 vTaskDelete 删除任务时:
- 任务会从所有就绪列表、阻塞列表、挂起列表等列表中移除;
- 并被放入“删除列表”中。

根据官方文档说明:
已删除任务的内存释放由空闲任务完成。
这意味着:
如果系统中存在任务删除行为,必须确保空闲任务能够获得执行机会,否则内存不会被及时释放。
第四:源码层面的补充说明
根据较新版本 FreeRTOS 的源码实现:
- 当任务调用
vTaskDelete(NULL)自删除时,确实由空闲任务负责最终释放内存; - 但当一个任务删除“其他任务”时,在某些版本中可能会直接释放被删除任务的内存。
具体行为可参考 tasks.c 中 vTaskDelete 的实现逻辑(约 2247~2313 行附近)。
因此:
不同版本内核在细节实现上可能略有差异,但总体机制一致。
细节注意事项
任务删除需要特别注意一个核心细节:vTaskDelete 并不会立即释放内存。
它只是把任务从调度系统中移除,并标记为“待删除”。
真正的内存释放(TCB 和任务栈),是由 Idle 任务在运行时调用:→ vPortFree() 完成的。
首先空闲任务的入口函数,是由内核提供的,如下所示:
void prvIdleTask( void *pvParameters );
空闲任务最核心的一步操作就是调用函数:
prvCheckTasksWaitingTermination()
用于释放任务的内存空间。源码如下图所示:


因此可以得到一个非常重要的结论:
如果 Idle 任务无法运行,被删除任务的内存就不会被释放。
时间一长,就可能导致:
→ 内存耗尽
也正因为这一点,任务删除在实际工程中:并不常用,甚至会刻意避免使用
主要有三个原因:
- 生命周期难控制任务一旦删除:
- 资源可能还没释放干净 → 内存泄漏
- 其他任务还在访问它的数据 → 直接出问题
- 实时系统更推荐“状态切换”
- FreeRTOS 更推荐: 任务常驻 + 状态控制
- 例如:挂起(vTaskSuspend)、阻塞(Delay / Queue / Event)
- 而不是:创建 → 删除 → 再创建
- 动态内存带来不确定性
- 频繁 delete + create 会导致:内存碎片、系统行为不可预测
- 这是嵌入式系统中的大忌
不过,任务删除也不是完全不用,以下场景是合理的:
- 一次性任务(初始化完成后自删除)
- 明确不会再使用的功能模块
- 系统内存确实非常紧张,确实需要主动回收空间
最后做一个总结:
- 任务删除 = 彻底销毁(不可恢复)
- 挂起 = 人为暂停(可恢复)
- 阻塞 = 等待条件(自动恢复)
总结
总结:
在 FreeRTOS 中:
- 任务不能通过 return 结束;
- 任务应通过 vTaskDelete 显式删除;
- 删除任务后内存由FreeRTOS内核机制自动释放(静态创建的任务由程序员手动释放);
- 若涉及任务删除,必须保证空闲任务能够运行;
- 建议删除任务后将句柄置 NULL,避免误访问。
- 如无绝对必要,不建议删除任务,某个任务如果暂时不用,FreeRTOS更建议挂起或阻塞它。
代码示例
如果你确实要删除任务,那么更推荐采用以下方式:
#include "stm32f10x.h" // STM32F10x 标准外设库头文件
#include "FreeRTOS.h" // FreeRTOS 核心头文件
#include "task.h" // FreeRTOS 任务相关 API
#include "DebugUSART1.h" // 调试串口模块(printf1)
// -------------------- 任务1 --------------------
void task1(void *arg) {
while (1) {
printf1("task1 running.\r\n");
vTaskDelay(1000);
/*
不推荐写法:任务自己删除自己
vTaskDelete(NULL);
NULL 表示删除当前任务
问题:
1. 外部无法控制任务何时结束
2. 资源管理容易出问题
3. 不利于系统统一管理
一般只用于一次性任务,不适合这种长期循环任务
*/
}
}
// -------------------- 任务2 --------------------
void task2(void *arg) {
while (1) {
printf1("task2 running.\r\n");
vTaskDelay(1000);
}
}
// -------------------- 管理任务 --------------------
void beginTask(void *arg) {
TaskHandle_t hand1 = NULL;
TaskHandle_t hand2 = NULL;
// 延时 5 秒后再创建两个任务
vTaskDelay(5000);
xTaskCreate(task1,
"Task1",
configMINIMAL_STACK_SIZE,
NULL,
tskIDLE_PRIORITY + 1,
&hand1);
xTaskCreate(task2,
"Task2",
configMINIMAL_STACK_SIZE,
NULL,
tskIDLE_PRIORITY + 1,
&hand2);
printf1("task1 and task2 created.\r\n");
while (1) {
vTaskDelay(5000);
if (hand1 != NULL) {
eTaskState state = eTaskGetState(hand1);
if (state == eRunning) {
printf1("task1 state: eRunning\r\n");
} else if (state == eReady) {
printf1("task1 state: eReady\r\n");
} else if (state == eBlocked) {
printf1("task1 state: eBlocked\r\n");
} else if (state == eSuspended) {
printf1("task1 state: eSuspended\r\n");
} else if (state == eDeleted) {
printf1("task1 state: eDeleted\r\n");
} else {
printf1("task1 state: unknown\r\n");
}
printf1("beginTask will delete task1.\r\n");
vTaskDelete(hand1);
hand1 = NULL;
printf1("task1 deleted.\r\n");
} else {
printf1("task1 has already been deleted.\r\n");
}
}
}
// -------------------- 主函数 --------------------
int main(void) {
// 1. 初始化调试串口
DebugUSART1_Init();
// 2. 创建 beginTask
xTaskCreate(beginTask,
"BeginTask",
configMINIMAL_STACK_SIZE,
NULL,
tskIDLE_PRIORITY + 2,
NULL);
// 3. 启动 FreeRTOS 调度器
vTaskStartScheduler();
// 理论上不会运行到这里
while (1) {
}
}
这种管理删除任务的方式,其优点是:
- 生命周期清晰、可控,任务何时创建、何时删除,都由管理任务统一控制,系统行为更可预测。
- 资源管理更安全,可以在删除前统一处理队列、外设、内存等资源,避免任务自删导致资源泄漏或状态混乱。
- 系统结构更清晰,易维护,符合“调度器管理任务”的设计思路,代码职责分明,后期扩展和排错更容易。
总之,如果真的要删除任务,就把任务当成资源一样,,交给“管理者”统一调度,而不是让它自己决定生死。
也就是说,与其让任务“自杀”,不如找人“让它体面”。

浙公网安备 33010602011771号