G
N
I
D
A
O
L

FreeRTOS学习(7)——任务列表

任务状态切换

在前面为了讲清楚FreeRTOS的任务调度机制,我们引入了“进程的三种状态”,也就是经典的三状态模型。

如下图所示:

进程的三种状态-图

在当时,我们就提到过:

进程的三种状态只是描述CPU 资源调度问题的逻辑结构模型。

而具体到实际的操作系统实现:

竞争CPU资源的实体名称可以改变,状态的名字也可以改变,状态可以更多样化,状态切换的结构可以更复杂化....

三状态模型只是一个“最简抽象框架”。

在FreeRTOS中:

  1. 竞争 CPU 的实体名称不再叫“进程”,而是叫“任务(Task)”
  2. FreeRTOS系统中,任务的实际状态有四种:就绪、运行、阻塞以及挂起。

也就是说:

FreeRTOS 在经典三状态模型的基础上,引入了“挂起态”这一额外状态(实际上这种状态模型设计还是非常简陋的)。

因此,在 FreeRTOS 中,任务的状态切换关系模型,会比经典三状态模型更加丰富。

下图所示,即为 FreeRTOS 任务的四状态切换结构模型图:

FreeRTOS任务四种状态切换模型-图

这张图描述的是 FreeRTOS 任务的四种状态流转关系:

  1. 就绪态(Ready)
  2. 运行态(Running)
  3. 阻塞态(Blocked)
  4. 挂起态(Suspended)

首先,刚刚创建的任务处于就绪态,这是一个前提条件。

Ready ↔ Running

任务状态流转的第一条主线是:Ready ↔ Running

在单核 MCU 上(比如STM32F103C8T6),同一时刻只能有一个任务上CPU,处于 Running 状态。

这不是调度策略问题,而是硬件物理限制决定的。

因此可以得到两个基本结论:

  1. 调度器只会从“就绪态任务集合”中选择一个任务运行,被选中的任务,从 Ready 状态进入 Running 状态。
  2. 当前运行的任务在被抢占或主动让出 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 中,挂起态指的是:

任务被人为地“从调度系统中移除”。

注意这个关键词——人为。

也就是说,是程序员主动调用挂起函数,使某个任务进入挂起态。

与阻塞态不同的是:

  1. 阻塞态是“等待某个条件达成”;
  2. 挂起态则是“被明确暂停,不参与调度”;
  3. 挂起态的任务虽然没有被删除,但实际会被调度器忽略,永远不会获取CPU执行权。
  4. 被挂起的任务,只有在恢复挂起后(即调用恢复函数),回到就绪态,才能重新参与 CPU 竞争。

需要特别注意两个细节:

  1. 处于任意状态的任务,都可以被挂起,直接进入挂起态;
  2. 被挂起的任务在恢复后,会回到就绪态,类似于阻塞态任务完成阻塞后的状态转换。

总结

至此,我们已经从逻辑模型层面,把 FreeRTOS 的四种任务状态以及三条主线梳理清楚:

  1. Ready ↔ Running —— 解决的是 CPU 在多个任务之间如何切换;
  2. Running → Blocked → Ready —— 解决的是任务为何主动离开 CPU;
  3. Ready ↔ Suspended —— 解决的是任务如何被人为移出调度体系。

可以看到,这一整套状态流转模型,本质上仍然围绕一个核心问题展开:

调度器如何管理“就绪态任务”?

因为无论是阻塞完成,还是恢复挂起,最终都会回到 Ready 状态,重新参与 CPU 竞争。

也就是说:

所有任务状态流转的“汇聚点”,都是——就绪态。

那么问题来了:

FreeRTOS 内核究竟是如何组织这些就绪任务的?

它的“就绪队列”到底采用了怎样的数据结构?

下面,我们正式进入源码层面,用画图的方式,来分析 FreeRTOS 就绪队列的内部实现机制。

FreeRTOS内核任务状态管理数据结构

FreeRTOS是一个简易轻量化的实时操作系统,所以其内核任务状态管理的数据结构部分设计的非常简单。

包括就绪队列,整体实现参考如下源码:

FreeRTOS内核就绪队列源码-图1

这一系列定义在task.c文件内部的静态成员,本质上就是 FreeRTOS 内核用来管理任务状态的核心数据结构。

实际上我们在研究FreeRTOS任务内存布局时,在内核堆数组的高地址上方,

存在 tasks.c 的静态全局变量区域,这里存放着 FreeRTOS 用来管理任务状态的核心链表结构。

.map文件中可以看到如下图所示:

FreeRTOS内核就绪队列源码-图2

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

FreeRTOS内核就绪队列源码-图3

这段区域就是 FreeRTOS 内核用来管理任务状态的核心数据结构的存放区域。

这一段源码,需要注意的关键点有两个:

第一,这些变量被定义为 static 修饰。

这意味着:

它们只在 tasks.c 文件内部可见,不允许外部文件直接访问。

也就是说:任务调度的核心数据结构,被严格封装在内核内部。

当然这也是可以预见的设计,毕竟这些数据结构被内核管理,不能够暴露给外界使用。

第二,这些静态变量都是 List_t 类型。

这实际上是一个结构体类型,用于表示存储一条链表。

类型定义如下:

 List_t 类型定义源码-图

源码中每一个该类型的静态成员,都代表着一条或多条链表,用来管理任务的状态。

任务不同状态的管理,使用不同的链表。

在命名时,从术语的角度,它们统一被命名为“XX列表”。

在命名时,从术语的角度,它们统一被命名为“XX列表”。

在命名时,从术语的角度,它们统一被命名为“XX列表”。

下面,我们具体来看一下,都存在哪些列表。

就绪列表数组

在 FreeRTOS 中,最重要的一种任务状态就是就绪态(Ready)。

那么,FreeRTOS内核如何管理就绪态任务呢?

实际上,FreeRTOS 使用了一个 List_t 类型的数组 来管理所有就绪态任务。

这一数据结构是 FreeRTOS 调度器中最核心的数据结构之一。

在源码中,对应的变量定义如下:

// 最前面的宏和static只是修饰符,不影响语义,在分析结构时可以暂时忽略它们
PRIVILEGED_DATA static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];

通常我们将它称为:

  1. 就绪队列,这种叫法符合操作系统课程中的概念,但不建议这么叫。
  2. 更准确的叫法应该是:就绪列表数组,在下面的课程中我们也会采用这种称呼。

所谓 就绪列表数组,本质上就是:

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;    // 挂起就绪列表

其中:

  1. xSuspendedTaskList 用于存放真正处于挂起态的任务;
  2. xPendingReadyList 则用于调度器暂停期间,暂存“待进入就绪态”的任务。

为了避免术语混淆,在后续课程内容中,我们将分别讲解:

  1. 挂起列表(Suspended Task List)
  2. 挂起就绪列表(Pending Ready List),也可以叫待就绪列表

挂起就绪列表涉及到下一章节的重要概念——临界区。

所以本章节中我们只讲一下挂起列表。

本小节先给出结构上的整体认知,具体的数据流转机制将在下面的小节中详细展开。

就绪列表数组(重点)

在FreeRTOS中,一个任务处于某个状态,实际上是通过它处于“哪个列表里”来进行判断的。

如果一个任务处于 就绪态(Ready) 或 运行态(Running),那么它实际上是处于“就绪列表数组”中的。

没有运行列表

首先需要特别强调一点:运行态并没有专属的列表。

因为:

  1. 运行态本质上只是“当前被选中的那个就绪任务”
  2. 它仍然存在于就绪列表中
  3. 只是调度器用一个当前TCB指针(pxCurrentTCB)指向了它

比如下列源码:

tasks.c文件的第442行,存储了一个当前TCB指针:

运行态指针-图

这个指针,就用于标记当前处于运行态的任务。(因为CPU只有一个,所以同一时刻只可能有一个任务上CPU)

再比如下列源码:

如果创建多个优先级相同的任务,那么在任务创建完成后,当前TCB指针实际上会指向最后一个创建的任务。

运行态指针-图2

运行态指针-图3

了解了上述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 ]; 

这里有几个关键点需要理解:

  1. pxReadyTasksLists 被称之为 就绪列表数组
  2. 数组长度由 configMAX_PRIORITIES 决定。
  3. 每一个数组元素,都是一个 List_t 类型的链表,也就是一个就绪列表

configMAX_PRIORITIES 是在 FreeRTOSConfig.h 中配置的最大优先级数量,默认情况下,其取值就是5。

任务优先级最大值宏-图

这意味着:

  1. 系统中最多支持 5 个优先级
  2. 优先级编号为 0 ~ 4

对应到就绪列表数组中,可以理解成:

FreeRTOS 内核默认维护的,是一个“按优先级分层”的就绪列表数组。

具体结构如下:

  1. 优先级 0 → 1条就绪列表
  2. 优先级 1 → 1条就绪列表
  3. 优先级 2 → 1条就绪列表
  4. 优先级 3 → 1条就绪列表
  5. 优先级 4 → 1条就绪列表

FreeRTOS内核就绪队列中,每一个优先级的任务,都有一条就绪列表。

如下图所示:

就绪列表数组-图

通常这种设计,不同优先级任务之间天然分层,结构清晰,便于内核调度任务的执行。

任务列表List_t类型

List_t类型,我们起一个专门的名称:任务列表,简称列表。

任务列表-图

任务列表是一个通用的链表类型,任务各个状态的列表都由此类型来表示。

那么一个“任务列表”的数据结构又是什么样的呢?

通过查阅源码我们知道,此类型中最核心的是三个成员:

  1. uxNumberOfItems
  2. pxIndex
  3. xListEnd

下面逐一进行解释。

成员:pxIndex

该成员的类型是ListItem_t *的指针类型。

ListItem_t类型,我们称之为:任务列表项,简称列表项。

任务列表项是任务列表的核心组成部分,用于表示链表中的一个结点,用来代表某个处于就绪/运行态的任务。

那成员pxIndex的作用是什么呢?

这个一个任务列表项指针类型成员,用于指向某个列表项。

该成员用于记录:当前列表,遍历到了哪个任务结点

当多个任务处于同一优先级时,FreeRTOS 通过移动 pxIndex 来实现时间片轮转。

简单理解就是:

  1. 上一次运行的是任务A
  2. 下一次调度,就从任务A后面的节点开始找

这就实现了“同优先级任务轮流执行”。

成员:xListEnd

该成员的类型是MiniListItem_t,这种类型,我们称之为:哨兵列表项。

这个成员是一个特殊的“哨兵节点”。

它并不代表具体的某一个任务,不存储有效数据。

它的作用是:

标记链表的结束位置

FreeRTOS 的任务列表,实际上一个“带哨兵结点的双向循环链表”。

xListEnd 是一个哨兵结点,它有两个作用:

  1. 作为链表的尾结点标记,始终指向链表的最后一个结点。
  2. 让链表操作逻辑更加统一,所有真实任务结点都必须插入在xListEnd 之前。

可以理解为:

这个一个永远存在于链表中的假节点,指示链表的尾部,方便进行插入结点的操作。

成员:uxNumberOfItems

成员的类型是UBaseType_t,这个类型我们太熟悉,它实际上就是一个无符号4字节整型。

再结合它的成员命名,这个成员的作用非常简单:

当前任务列表中,一共有多少个结点(也就是多少个任务)

这是一个计数器,用于记录链表中真实结点数量。

注意,哨兵结点不属于链表的真实结点,该计数器不会计算它。

在FreeRTOS内核调度时,调度器可以直接检查 uxNumberOfItems 是否为 0,从而判断某个优先级是否有任务。

如果该成员取值是0,说明该优先级没有就绪任务,那么就无需再去遍历链表了。

从而提升系统任务调度的效率。

任务列表项ListItem_t 类型

为了能够完整画出“就绪列表数组”的数据结构图,我们还需要深入研究一下任务列表项ListItem_t 类型。

List_t 只是一个存储链表结构的容器,真正的链式结构的结点类型是任务列表项ListItem_t 类型。

此类型的源码如下:

任务列表项类型-图1

在FreeRTOS中为了简化代码,避免反复进行类型转换,所以:

  1. 任务列表List_t类型,是所有状态列表都共用的类型。
  2. 任务列表项ListItem_t 类型,所有状态的任务也共用同一个类型。

这就导致一个问题:某种状态的任务,其任务列表项的成员,并不是每一个成员都有作用。

比如处于就绪态的任务,只有以下成员有作用:

  1. pxNext
  2. pxPrevious
  3. pvOwner
  4. pvContainer

下面来逐一介绍这四个成员,也是最通用的四个成员,任务无论处于何种状态,都涉及这四个成员。

成员:pxNext和pxPrevious

这两个成员的类型都是任务列表项ListItem_t 的指针类型,它们的作用就非常明显了:

  1. pxNext指向链表的下一个任务列表项节点
  2. pxPrevious指向链表的上一个任务列表项节点

任务列表,这条链表中的每一个任务结点都会存储下一个和上一个结点的指针。

由此可见,任务列表是一条双向循环链表。

成员:pvOwner

该成员是任务调度过程中,调度器能够定位任务本体的核心桥梁。

pvOwner是一个指针类型,而且是一个指向任务TCB结构的指针。

也就是说:

每一个任务列表项结点本身并不存储任务信息,而是通过 pvOwner 指针,间接指向任务的 TCB。

因此可以这样理解,FreeRTOS 内核调度器在遍历就绪链表时:

  1. 先获取链表中的 ListItem_t
  2. 再通过 pvOwner 找到对应任务的 TCB
  3. 最终通过 TCB 中保存的栈指针、优先级等信息完成任务切换

所以本质上:

调度器并不是直接在操作“任务函数”,而是在操作“链表节点”,再通过链表节点中的 pvOwner,间接定位到任务 TCB。

可以用一句话总结:

FreeRTOS 的调度过程,本质是“链表节点 → pvOwner → TCB → 栈指针”的层层定位过程。

这样设计的好处是:

链表结构与任务控制结构解耦,提高了内核结构的通用性和可扩展性。

成员:pvContainer

在任务列表项结构体类型中,成员pvContainer的类型是任务列表List_t类型。

所以这个成员的作用是:记录当前任务列表项属于哪个任务列表。

也就是说:

每一个 ListItem_t,都知道自己被挂在哪个 List_t 里。

这个成员有啥作用呢?

我们来设想一个场景。

假如某一个任务需要被删除,那么就需要从任务列表中移除它的列表项。

如果 ListItem_t 不知道自己属于哪一个列表,那么只能这么做:

  1. 遍历所有可能的任务列表
  2. 在每个任务列表中查找该任务列表项结点
  3. 找到之后再执行删除操作

这种方式不仅麻烦,而且效率低。

而有了 pvContainer 成员之后,情况就完全不同了。

删除流程变成:

  1. 通过 pvContainer 直接获取所属的 List_t
  2. 直接移除对应的任务列表项
  3. 随即更新任务列表中的节点数量与指针关系

整个过程无需遍历其他列表。

如此就简化了操作,更提升了任务调度的性能。

哨兵列表项MiniListItem_t类型

为了能够画出就绪列表数组的数据结构,我们还需要深入了解一下哨兵列表项MiniListItem_t类型。

哨兵列表项类型非常简单,因为它并不存储有效任务数据。

其类型定义源码,如下图所示:

哨兵列表项类型源码-图

在每一个 List_t 任务列表中,都包含一个成员:

MiniListItem_t xListEnd;

这个成员,就是整条链表的“尾部标记”,不存储有效数据,只是一个哨兵结点。

特别需要留意的是:

哨兵结点直接存储在结构体中作为一个成员,而不是存储指针。

由此可见,任务列表的链表是一条双向循环链表,并且是一个带哨兵结点的双向循环链表。

这样设计的好处是:

  1. 插入和删除效率更高
  2. 不需要单独处理头尾特殊情况

某种程度上来说,是一种空间(存储更多结点指针)换时间(效率提升)的做法。

实践中使用链表,多数都会使用这种双向循环链表,用更多空间占用来换取效率的提升。

就绪任务数组的数据结构图

经过上面的一通讲解和描述,FreeRTOS内核的就绪任务数组的数据结构图,就基本可以很清晰的画出来了。

但还是有一些小细节需要补充一下:

  1. 任务列表中的哨兵结点和普通结点类型是不同的。
    1. 但实际上在FreeRTOS内核代码中,会直接把哨兵结点当成普通结点使用。
    2. 这是做的好处是,可以让哨兵结点和普通结点之间可以互相指向。
    3. 之所以可以这么做是因为哨兵结点和普通结点,它们前半部分(前三个成员)是完全一致。
    4. 这种结构体类型的“前缀兼容设计”,在各种操作系统内核中都很普遍,目的是为了节约空间,简化代码实现。
  2. 哨兵结点名为xListEnd,它的作用是标记双向链表的尾部,这一个细节需要注意。
  3. 在初始化任务列表时,pxIndex成员会初始化为指向哨兵结点,当然FreeRTOS并不会把哨兵结点当成真实任务去调度。

上述描述可以参考内核函数vListInitialise()的实现:

任务列表初始化-图

以上。

下面我们结合不同的情况,来画就绪任务列表的数据结构示意图。

不存在任务结点的就绪任务列表

如果某个优先级的就绪任务列表不存在任何真实任务结点,那么它的数据结构示意图。如下图所示:

不存在任务结点的就绪任务列表-图

哨兵结点的前驱与后继指针都指向自己。

pxIndex标记当前遍历结点,也指向哨兵结点。

一个任务结点的就绪任务列表

如果某个优先级的就绪任务列表,只存在一个任务结点。那么它的数据结构示意图。如下图所示:

一个任务结点的就绪任务列表-图

解释一下这张图:

  1. 哨兵结点和普通结点互相指向,各自存储的两个指针都指向对方。
  2. pxIndex指向唯一的普通结点,如果所有处于就绪态的任务中,当前优先级是最高的,那么此任务就会上CPU,且不会进行时间片轮转。
  3. pvContainer指向当前任务列表。

多个任务结点的就绪任务列表

如果某个优先级的就绪任务列表,存在多个任务结点。那么它的数据结构示意图。如下图所示:

多个任务结点的就绪任务列表-图

解释一下这张图:

  1. 每个任务列表项两个指针的指向都省略了,它们一个指向自身任务的TCB,一个指向当前任务列表。
  2. 严格来说,循环链表没有所谓的“头尾”,但图中还是把哨兵结点当成尾部,这么做的目的是为了区分前驱和后继的方向。

整体数据结构模型图

整体数据结构模型图,如下图所示:

整体数据结构模型-图

任务的就绪列表数组,其结构搞清楚后,那么FreeRTOS内核,在“就绪态和运行态”之间的任务调度,是如何进行的呢

就绪任务调度机制

在FreeRTOS中,如何来决定当前哪一个任务上CPU呢?

很简单:

  1. FreeRTOS 的任务调度,就是在就绪列表数组中,找到最高优先级的非空链表,然后从该链表中选出一个任务执行。
  2. 如果最高优先级的任务具有多个,那么采用时间片轮转机制。

这个过程需要解决的第一个问题就是:

如何找到最高优先级的任务列表?

找到最高优先级的任务列表

最简单办法是从任务列表数组的末尾开始遍历数组元素,直到找到一条不为空的链表。

但这个操作涉及遍历,从性能上还是有点差。

在开启下列宏的前提下(实际上默认开启):

#define configUSE_PORT_OPTIMISED_TASK_SELECTION 1

内核维护一个关键变量:

当前最高优先级-图

简单来说,通过这个静态全局变量,内核可以一步到位不需要遍历就知道当前最高优先级的任务列表。

那这是怎么实现的呢?

uxTopReadyPriority的类型是UBaseType_t,通常就是一个4字节无符号整型。

但只有其中的低 configMAX_PRIORITIES 位有效,通常就是低 5 位使用,高27则属于保留位。

假如系统当前存在:

  1. 优先级4 的就绪任务
  2. 优先级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三个任务,它们的优先级是一致的,会进入同一个就绪列表。

那么这三个任务的任务列表项结点,加上哨兵结点,是如何组成一条链表的呢?

为了解决这个问题,我们就需要查看相关源码了:

任务创建时插入就绪列表-源码图

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

任务创建时插入就绪列表-源码图2

也就是说:

依次序创建优先级一致的Task123三个任务,就绪列表如下:

哨兵 → Task1→ Task2 → Task3 → 哨兵

就绪列表时间片轮转

现在这三个任务处于同一个就绪列表,它们的优先级是一致的,所以采用时间片轮转。

这个过程主要涉及两个核心的变量:

  1. pxIndex:标记目前已遍历的任务结点,一开始指向哨兵结点。
  2. pxCurrentTCB:当前TCB指针,它指向真正上CPU,处于运行态的任务TCB。

接下来,调度器在每一次需要选择任务时,都会执行如下逻辑:

  1. 找到当前最高优先级的就绪列表(本例中最高优先级是1)。
  2. 在该就绪列表中,通过 pxIndex 向后移动一个结点,将移动到的结点对应的 TCB 赋值给 pxCurrentTCB。
  3. 随即完成任务的上下文切换,实现时间片轮转机制。

进行任务上下文切换的函数是:

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

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

通过Keil5的调试模式查看就绪列表-图2

这里使用了下列表达式来查看优先级为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中,维护了这样的一个全局系统节拍计数器:

xTickCount源码-图

在系统启动时,从0开始计数,每过一个系统节拍就自增1。

这个计数器就可以表示FreeRTOS内核的全局时间。

那么一个系统节拍是多长时间呢?

这个我们早就知道了,在配置文件中配置了下面的宏:

#define configTICK_RATE_HZ          ( ( TickType_t ) 1000 )

这就是说:1秒钟分为1000个系统节拍,1个系统节拍就是1ms。

在内核源码文件tasks.c中,还存在这样的一个函数xTaskIncrementTick(),它会在内核每过一个系统节拍时,为此全局变量自增1:

xTickCount源码-图2

当前这里面的注释也提到了:

  1. xTickCount 的类型是 TickType_t(无符号),通常就是一个无符号4字节整型数据。
  2. xTickCount 每个 系统节拍 自增 1,持续累加必然发生溢出。
  3. 全局系统节拍计数器的最大取值是4294967295,约等于每计时49.71天就会产生溢出。
  4. FreeRTOS把这个现象称之为“系统节拍回绕”,也就是全局系统节拍计数归0。

xTaskGetTickCount()函数,其函数原型如下:

TickType_t xTaskGetTickCount( void );

该函数的返回值,就是当前系统全局 Tick 的计数值。

如果你想获取当前系统 Tick,可以直接调用此函数。

任务列表项中的成员:xItemValue

在就绪列表中,某个任务列表项的成员xItemValue通常是没有意义的。

但是在延时阻塞列表中,该成员是一个起着核心作用的成员:

它会存储当前任务“苏醒”的全局系统节拍计数,也就是记录当前任务结束延时阻塞,回归就绪态的时间。

可以通过查阅vTaskDelay()等函数的源码,找到prvAddCurrentTaskToDelayedList()函数,来了解这一个设定:

xItemValue成员-源码图

xItemValue成员-源码图2

既然每一个处于延时阻塞列表的任务结点,都记录了自己的苏醒时间,那么接下来就好办了:

只需要把结点按照苏醒时间排序,早苏醒的(节拍计数小的)排在前面,晚苏醒的排在后面。

由于延时阻塞列表是按唤醒时间升序排列的,调度器就无需遍历整条链表了。

而是仅需要检查头部的结点,只要头部的结点不苏醒,剩下的结点任务必然会持续阻塞。

两个延时阻塞列表的作用

了解了前面的知识点,那么就可以解释一个问题了:

为什么延时阻塞列表有两个。

这个问题的答案,并不复杂,核心原因只有一个:

系统的全局 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;

这两个延时阻塞列表,它们究竟是“当前延时”还是“回绕延时”,要看两个指针哪一个指向它们:

  1. 如果pxDelayedTaskList指针指向了xDelayedTaskList1,那么xDelayedTaskList1就是当前延时阻塞列表。
  2. 如果pxOverflowDelayedTaskList指针指向了xDelayedTaskList1,那么xDelayedTaskList1就是回绕延时阻塞列表。

回绕后延时阻塞列表的处理

xDelayedTaskList1和xDelayedTaskList2谁是当前延时阻塞列表,谁是回绕延时阻塞列表?

这当然不是固定的,会进行轮流的切换。

在内核第一次创建任务时,就会初始化内核任务列表的数据结构,涉及一个函数prvInitialiseTaskLists()

其源码如下图所示:

延时阻塞列表的切换-图

可以看到,在系统启动后,一开始:

  1. pxDelayedTaskList指针指向了xDelayedTaskList1,xDelayedTaskList1是当前延时阻塞列表。
  2. pxOverflowDelayedTaskList指针指向了xDelayedTaskList2,xDelayedTaskList2是回绕延时阻塞列表。

还是回到上面的场景中:

  1. 当前的系统节拍计数是:xTickCount = 0xFFFFFFF0
  2. 任务A的xItemValue成员中存储的数据是:0x00000022
    1. 这个唤醒时间数值显然小于当前系统节拍计数,所以它是一个回绕后的时间
    2. 于是任务A进入延时阻塞状态后,会进入回绕延时阻塞列表。
    3. 也就是xDelayedTaskList2列表,pxOverflowDelayedTaskList指向这个列表。
  3. 任务B的xItemValue成员中存储的数据是:0xFFFFFFF5
    1. 这个唤醒时间数值显然大于当前系统节拍计数,所以它不是一个回绕后的时间
    2. 于是任务B进入延时阻塞状态后,会进入当前延时阻塞列表。
    3. 也就是xDelayedTaskList1列表,pxDelayedTaskList指向这个列表。

随后系统中的流程是这样的:

  1. 当全局系统节拍计数达到0xFFFFFFF5时,任务B会结束阻塞苏醒,回归就绪态,从xDelayedTaskList1列表移除。
  2. 当全局系统节拍计数达到0xFFFFFFFF且继续进入下一个节拍时:
    1. 此时全局系统节拍计数溢出回绕,xTickCount = 0。
    2. pxOverflowDelayedTaskList和pxDelayedTaskList两个指针需要交换指向的对象。
    3. 随后xDelayedTaskList2列表变成了当前延时阻塞列表,xDelayedTaskList1列表变成了回绕延时阻塞列表。
  3. 当全局系统节拍计数达到0x00000022时,任务A会结束阻塞苏醒,回归就绪态,从xDelayedTaskList2列表移除。
  4. .....

xDelayedTaskList1xDelayedTaskList2 这两个链表,会 交替作为“当前延时列表”和“回绕延时列表” 使用。

从而共同完成 FreeRTOS 中 延时阻塞任务的管理与调度。

在系统运行过程中:

  1. pxDelayedTaskList 指针指向 当前延时列表
  2. pxOverflowDelayedTaskList 指针指向 回绕延时列表

当系统全局 Tick 计数器发生 回绕时,这两个指针会进行一次 交换。

交换之后:

  1. 原来的回绕延时列表变成新的当前延时列表
  2. 原来的当前延时列表则变成新的回绕延时列表

这样一来,FreeRTOS 就可以继续按照新的时间周期来管理延时任务。

pxDelayedTaskList和pxOverflowDelayedTaskList这两个指针交换的源码如下图所示:

延时阻塞列表的切换-图2

延时阻塞列表的切换-图3

以上。

这套设计的精妙之处在于:

  1. 利用无符号 Tick 计数器的自然回绕特性
  2. 将时间范围划分为两个周期区间
    1. 一个周期对应当前延时列表
    2. 另一个周期对应回绕延时列表。
  3. 通过交换两个指针完成时间周期切换,不需要重新计算或调整列表结构。
  4. 整个过程中无需移动任何列表节点。

因此,从实现思想上来看,这种设计本质上是:

通过增加一个链表结构,将复杂的时间判断问题转化为简单的指针交换操作。

也可以理解为:

用结构设计降低算法复杂度。

总之,FreeRTOS 的延时机制,并不是简单“等待多少毫秒”,而是一个基于 Tick 计数、链表管理和回绕处理的完整时间调度系统。

关于这一点,关于FreeRTOS延时函数的具体原理,我们等到后面讲定时器时再详谈。

FreeRTOS提供的延时阻塞函数

前面我们已经讲完了 延时阻塞列表 的实现机制。大家已经知道:FreeRTOS系统中存在一个 全局 Tick 计数器 xTickCount

任务调用延时函数之后,会发生这样几件事情:

  1. 当前任务从 就绪列表 中移除
  2. 任务被插入 延时阻塞列表
  3. 当全局 Tick 计数器 xTickCount 增长到任务设定的唤醒时间(即 xItemValue
  4. 任务就会重新进入 就绪列表,回到就绪态

也就是说:

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()函数的实现逻辑,和上面讲的延时阻塞列表的原理是完全一致的:

该内部函数会完成几个操作:

  1. 当前任务从 就绪列表 中移除
  2. 计算任务的 唤醒时间 = 此函数调用时的全局Tick + xTicksToDelay
  3. 将任务插入 延时阻塞列表

当系统 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() 的工作方式

这个函数有两个参数,它们的含义分别是:

  1. pxPreviousWakeTime,用于记录 上一次任务唤醒的时间,需要传参一个指针类型,因为函数内部会修改你传参的唤醒时间。
  2. 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() 的特点:

  1. 相对延时
  2. 延时时间相对于系统当前全局 Tick
  3. 可能产生周期漂移
  4. 适合工程中的简单延时,不在意精度的场景。

vTaskDelayUntil() 的特点:

  1. 绝对延时
  2. 按固定时间点唤醒任务
  3. 周期更稳定
  4. 适合周期任务

了解: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)。

FreeRTOS任务状态流转-图

关于挂起态,我们需要了解以下几点基本概念:

  1. 处于任意调度状态的任务,都可以被 vTaskSuspend() 挂起,进入挂起态。(甚至可以挂起后再挂起,但通常没有意义)
  2. 被挂起的任务不参与任务调度,也不会因为条件满足而自行回归就绪态。
  3. 进入挂起态的任务,实质上是被移出原有列表,并插入内核维护的挂起列表xSuspendedTaskList。
  4. 在其他任务中调用 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) {
    }
}

系统中一共存在三个任务:

  1. task1(优先级 4)
  2. task2(优先级 4)
  3. beginTask(优先级 3)

Task1 的行为分析:

task1 在第一次运行时,会主动调用:

vTaskSuspend(NULL);

将自己挂起。

这里有一个非常关键的点:

这是任务“主动把自己从调度系统中移除”。

执行该函数后,会发生以下事情:

  1. 任务从 就绪列表 中移除
  2. 被插入 挂起列表(Suspended List)
  3. 不再参与任何调度

也就是说:

一旦进入挂起态,这个任务就从调度器关注的状态列表中移出,不再参与 CPU 竞争。

想要让它重新运行,必须调用:

vTaskResume(hand1);

由外部将其恢复到就绪态。


beginTask 的控制逻辑分析:

beginTask 的作用是对 task1 进行“启停控制”。

其行为可以描述为:

  1. 每隔 5 秒恢复 task1
  2. 再隔 5 秒挂起 task1

形成一个周期性的控制流程:

恢复 → 运行 → 挂起 → 停止 → 再恢复

因此现象非常直观:

  1. task1 会周期性“出现 / 消失”
  2. 挂起期间完全没有任何打印或执行行为

可以把它理解为:

beginTask 在动态“开关” task1。


调度行为分析:

当前系统优先级关系如下:

  1. task1 / task2:优先级 4(高优先级)
  2. beginTask:优先级 3(低优先级)

调度过程可以分三种情况理解:

  1. 当 task1 和 task2 都处于就绪态,两个任务优先级相同
    1. 启用时间片轮转
    2. 表现为:task1 和 task2 交替运行
  2. 当 task1 被挂起,此时优先级 4 只剩 task2,表现为:task2 独占 CPU
  3. 当 task2 调用 vTaskDelay() 进入阻塞态,高优先级任务全部阻塞
    1. 此时调度器才会切换到 beginTask
    2. 表现为:低优先级任务只有在高优先级“让出CPU”时才能运行

最终结论:

挂起态,本质是一种:“人为控制的冻结状态”

它与阻塞态有本质区别:

  1. 阻塞态,由系统条件触发恢复(时间 / 事件)
  2. 挂起态,必须由外部手动恢复(vTaskResume)

可以用一句话总结:

阻塞是“等条件”,挂起是“被人为停掉”。

挂起列表

tasks.c 中可以找到如下定义:

PRIVILEGED_DATA static List_t xSuspendedTaskList;

这说明:

FreeRTOS 内核内部维护了一个挂起列表,用于存放处于挂起态的任务。

它的数据类型是:

List_t

也就是 FreeRTOS 内部的双向循环链表结构。

调用vTaskSuspend函数后,挂起操作的源码路径大体上是

/* 1. 从原任务列表移除任务 */
uxListRemove( &( pxTCB->xStateListItem ) );

/* 2. 插入挂起列表 */
vListInsertEnd( &xSuspendedTaskList, &( pxTCB->xStateListItem ) );

这两步非常关键:

  1. 第一步:把任务从原有状态列表中摘除
  2. 第二步:插入挂起列表

这意味着:

挂起态任务一定只存在于 xSuspendedTaskList 中。

任务处于任何状态都可以进行挂起,那么挂起时如果任务原本在阻塞态怎么办?

这是很多人容易误解的地方。

任务如果原本处于阻塞态,调用 vTaskSuspend() 时:

  1. 内核还是会把任务从阻塞列表移除
  2. 然后再插入挂起列表xSuspendedTaskList

并且在调用恢复挂起函数后,直接让任务去到就绪态。

所以:

  1. 如果挂起前任务处于阻塞状态,那么挂起操作会直接打破它的“阻塞条件”,直接进入挂起态。
  2. 恢复挂起时,直接进入就绪态。

而FreeRTOS内核调度器只会在就绪列表pxReadyTasksLists中寻找优先级最高的任务,进入运行态。

某个任务一旦进行挂起列表xSuspendedTaskList,那么调度器就不会管这个任务了。

因此,挂起列表是“调度系统之外”的一个隔离区。

它的设计意义是:

  1. 提供人为冻结任务的能力;
  2. 不参与优先级竞争;
  3. 不参与时间推进;
  4. 不参与事件唤醒;

任务删除

FreeRTOS 中任务的退出方式问题

在 FreeRTOS 中,一个任务在执行过程中,如果涉及“退出”,必须遵循内核规定的方式。

任务的退出方式主要有以下几种情况。


第一:不允许通过 return 退出任务

在 Linux 的进程或线程模型中,可以通过在入口函数中执行 return 来结束线程。

但是在 FreeRTOS 中:

  1. 任务不允许以任何方式从入口函数中 return;
  2. 也不允许执行到入口函数末尾自然结束。

这是因为:

FreeRTOS 任务的运行环境依赖于内核调度器管理,一旦入口函数返回,将导致栈和调度状态不可控。

因此:

任务函数必须始终处于一个无限循环中,或者通过 API 显式删除自身。


第二:可以通过 vTaskDelete 删除任务

FreeRTOS 提供了任务删除接口:

void vTaskDelete(
    TaskHandle_t xTaskToDelete
);

使用该函数前,必须在 FreeRTOSConfig.h 中启用:

#define INCLUDE_vTaskDelete 1

参数说明:

  1. 传入具体任务句柄 → 删除指定任务;
  2. 传入 NULL → 删除当前任务(自删除)。

第三:任务删除后的内核行为

当调用 vTaskDelete 删除任务时:

  1. 任务会从所有就绪列表、阻塞列表、挂起列表等列表中移除;
  2. 并被放入“删除列表”中。

删除列表-源码图

根据官方文档说明:

已删除任务的内存释放由空闲任务完成。

这意味着:

如果系统中存在任务删除行为,必须确保空闲任务能够获得执行机会,否则内存不会被及时释放。


第四:源码层面的补充说明

根据较新版本 FreeRTOS 的源码实现:

  1. 当任务调用 vTaskDelete(NULL) 自删除时,确实由空闲任务负责最终释放内存;
  2. 但当一个任务删除“其他任务”时,在某些版本中可能会直接释放被删除任务的内存。

具体行为可参考 tasks.c 中 vTaskDelete 的实现逻辑(约 2247~2313 行附近)。

因此:

不同版本内核在细节实现上可能略有差异,但总体机制一致。

细节注意事项

任务删除需要特别注意一个核心细节:vTaskDelete 并不会立即释放内存。

它只是把任务从调度系统中移除,并标记为“待删除”。

真正的内存释放(TCB 和任务栈),是由 Idle 任务在运行时调用:→ vPortFree() 完成的。

首先空闲任务的入口函数,是由内核提供的,如下所示:

void prvIdleTask( void *pvParameters );

空闲任务最核心的一步操作就是调用函数:

prvCheckTasksWaitingTermination()

用于释放任务的内存空间。源码如下图所示:

任务删除源码-图

任务删除源码-图2

因此可以得到一个非常重要的结论:

如果 Idle 任务无法运行,被删除任务的内存就不会被释放。

时间一长,就可能导致:

→ 内存耗尽

也正因为这一点,任务删除在实际工程中:并不常用,甚至会刻意避免使用

主要有三个原因:

  1. 生命周期难控制任务一旦删除:
    1. 资源可能还没释放干净 → 内存泄漏
    2. 其他任务还在访问它的数据 → 直接出问题
  2. 实时系统更推荐“状态切换”
    1. FreeRTOS 更推荐: 任务常驻 + 状态控制
    2. 例如:挂起(vTaskSuspend)、阻塞(Delay / Queue / Event)
    3. 而不是:创建 → 删除 → 再创建
  3. 动态内存带来不确定性
    1. 频繁 delete + create 会导致:内存碎片、系统行为不可预测
    2. 这是嵌入式系统中的大忌

不过,任务删除也不是完全不用,以下场景是合理的:

  1. 一次性任务(初始化完成后自删除)
  2. 明确不会再使用的功能模块
  3. 系统内存确实非常紧张,确实需要主动回收空间

最后做一个总结:

  1. 任务删除 = 彻底销毁(不可恢复)
  2. 挂起 = 人为暂停(可恢复)
  3. 阻塞 = 等待条件(自动恢复)

总结

总结:

在 FreeRTOS 中:

  1. 任务不能通过 return 结束;
  2. 任务应通过 vTaskDelete 显式删除;
  3. 删除任务后内存由FreeRTOS内核机制自动释放(静态创建的任务由程序员手动释放)
  4. 若涉及任务删除,必须保证空闲任务能够运行;
  5. 建议删除任务后将句柄置 NULL,避免误访问。
  6. 如无绝对必要,不建议删除任务,某个任务如果暂时不用,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) {
    }
}

这种管理删除任务的方式,其优点是:

  1. 生命周期清晰、可控,任务何时创建、何时删除,都由管理任务统一控制,系统行为更可预测。
  2. 资源管理更安全,可以在删除前统一处理队列、外设、内存等资源,避免任务自删导致资源泄漏或状态混乱。
  3. 系统结构更清晰,易维护,符合“调度器管理任务”的设计思路,代码职责分明,后期扩展和排错更容易。

总之,如果真的要删除任务,就把任务当成资源一样,,交给“管理者”统一调度,而不是让它自己决定生死。

也就是说,与其让任务“自杀”,不如找人“让它体面”。

posted @ 2026-04-28 21:47  星夜夏空  阅读(14)  评论(0)    收藏  举报