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

文章目录

任务状态切换

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

如下图所示:

在当时,我们就提到过:

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

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

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

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

在FreeRTOS中:

  1. 竞争 CPU 的实体名称不再叫"进程",而是叫"任务(Task)"
  2. 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是一个简易轻量化的实时操作系统,所以其内核任务状态管理的数据结构部分设计的非常简单。

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

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

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

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

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

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

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

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

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

这意味着:

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

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

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

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

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

类型定义如下:

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

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

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

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

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

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

就绪列表数组

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

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

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

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

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

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

通常我们将它称为:

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

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

FreeRTOS 使用一个 List_t 类型的数组,来分别管理不同优先级下的就绪任务。

关于就绪列表数组的具体内部实现方式,以及它如何进行任务状态的管理,我们将在下面的小节中进行详细分析。

延时阻塞列表

如果某个任务因延时进入阻塞状态,例如调用了 vTaskDelay() 函数,

那么此时任务会发生一次状态转换:

Running → Blocked

也就是说,任务主动放弃 CPU 执行权,不再参与 CPU 竞争,进入阻塞状态。

在 FreeRTOS 内核中,所有"因时间而阻塞"的任务,都会被插入到一个专门的链表结构中进行管理。

对应源码中的关键变量如下:

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;

通常我们将它们统称为:延时阻塞列表

当然实际上延时阻塞列表有两个,xDelayedTaskList1和xDelayedTaskList2。

至于这些数据结构是如何完成任务的阻塞状态管理,以及为何需要两张延时阻塞列表配合使用,我们之后会详细分析。

挂起相关列表

如果某个任务被挂起,进入挂起态,那么该任务相当于被人为暂停,暂时不参与 CPU 执行权的竞争。

此时,任务会从原有状态列表中移除,并被插入到专门的挂起列表中进行管理。

在 FreeRTOS 内核中,与"挂起"相关的列表结构并不只有一个。

对应源码中的关键变量包括:

对应源码变量如下:

c 复制代码
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指针实际上会指向最后一个创建的任务。

了解了上述FreeRTOS内核源码,对于下面代码:

c 复制代码
#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。

就绪列表数组本身

就绪列表数组相关的源码如下:

c 复制代码
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 类型。

此类型的源码如下:

在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 任务列表中,都包含一个成员:

c 复制代码
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. 如果最高优先级的任务具有多个,那么采用时间片轮转机制。

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

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

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

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

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

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

c 复制代码
#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三个任务,它们的优先级是一致的,会进入同一个就绪列表。

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

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

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

也就是说:

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

哨兵 → Task1→ Task2 → Task3 → 哨兵

就绪列表时间片轮转

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

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

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

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

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

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

c 复制代码
static void vTaskSwitchContext( void )

一路向内部寻找核心代码,最终找到源码如下:

由于pxCurrentTCB在任务完成创建后,已经指向了"Task3",所以实际上在调度器启动后,这三个任务的执行顺序是:

Task3 → Task1 → Task2 → Task3 → Task1 → Task2 ...

其中Task3会第一个上CPU是因为,任务创建后当前TCB指针指向了Task3

随后执行Task1,这是因为Task1是哨兵结点的下一个结点,pxIndex 向后移动一个结点就是它。

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

将上述案例的代码放入Keil5中编译烧录:

c 复制代码
#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的任务列表:

c 复制代码
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中,可以看到这样几行变量定义:

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内核的全局时间。

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

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

c 复制代码
#define configTICK_RATE_HZ          ( ( TickType_t ) 1000 )

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

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

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

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

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

c 复制代码
TickType_t xTaskGetTickCount( void );

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

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

任务列表项中的成员:xItemValue

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

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

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

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

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

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

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

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

两个延时阻塞列表的作用

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

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

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

系统的全局 Tick 计数器是会"回绕"的。

现在思考一个场景。

假设当前:xTickCount = 0xFFFFFFF0

某个任务A调用:

c 复制代码
vTaskDelay( 50 );

那么它的唤醒时间为:

xTimeToWake = 当前 Tick + 延时 Tick

= 0xFFFFFFF0 + 50

= 发生溢出 → 0x00000022

这个唤醒时间节拍计数,其数值由于溢出变得很小。

另一个任务B调用:

c 复制代码
vTaskDelay( 5 );

那么它的唤醒时间为:

xTimeToWake = 当前 Tick + 延时 Tick

= 0xFFFFFFF0 + 5

= 0xFFFFFFF5

这个唤醒时间节拍计数,由于没有溢出,所以是一个比较大的数值。

问题来了。

如果我们只有一个延时链表,并且简单按"唤醒时间升序排序"。

那么这两个任务应当先唤醒谁呢?

从时间顺序上看:B 应当先唤醒

但实际上,如果只有一条延时阻塞列表,那么A任务由于唤醒时间小,处在链表前面,它会被先唤醒。

这显然是不正确的,不合理的。

为了解决这个问题,FreeRTOS的设计者将延时阻塞列表分为了两类,对应两类阻塞任务:

第一类延时阻塞列表,存放苏醒时间在溢出前的任务结点,这就是"当前延时阻塞列表"。

也就是指针pxDelayedTaskList指向的延时阻塞列表。

第二类延时阻塞列表,存放苏醒时间在溢出后的任务结点,这就是"溢出/回绕延时阻塞列表"。

也就是指针pxOverflowDelayedTaskList指向的延时阻塞列表。

也就是说:

c 复制代码
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这两个指针交换的源码如下图所示:

以上。

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

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

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

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

也可以理解为:

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

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

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

FreeRTOS提供的延时阻塞函数

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

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

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

也就是说:

FreeRTOS 的延时,本质上是通过延时阻塞列表 + 全局 Tick 计数实现的。

知道了这个本质,那么我们就可以来一起看一下FreeRTOS 内核为我们提供的 任务级延时函数

在实际编程中,最常用的用于延时,使任务进入阻塞态的函数有两个:

c 复制代码
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后重新回到就绪态。

为什么是大概呢?

要回答这个问题,我们可以先思考一个更简单的问题:

c 复制代码
vTaskDelay(1);

是不是精确延时 1 个系统节拍?

答:大概率不是。

为什么呢?

原因在于vTaskDelay(1) 的唤醒时间是这样计算的:

c 复制代码
xTimeToWake = xTickCount + xTicksToDelay

如果调用:

c 复制代码
vTaskDelay(1)

则:

c 复制代码
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() 的函数原型如下:

c 复制代码
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

那么任务的一次完整执行时间,实际上是:

c 复制代码
任务执行时间  +  vTaskDelay时间

也就是说:

c 复制代码
任务的实际周期 = 任务执行时间 + 1000 Tick

于是任务周期实际上变成了 :

c 复制代码
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)

此函数的典型调用方式如下:

c 复制代码
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 中,经常会看到这样的写法:

c 复制代码
vTaskDelay(pdMS_TO_TICKS(1000));

其中 pdMS_TO_TICKS() 实际上是一个宏函数,在源码 projdefs.h 文件中可以看到它的定义:

c 复制代码
#ifndef pdMS_TO_TICKS
 #define pdMS_TO_TICKS( xTimeInMs )    ( ( TickType_t ) ( ( ( uint64_t ) ( xTimeInMs ) * ( uint64_t ) configTICK_RATE_HZ ) / ( uint64_t ) 1000U ) )
#endif

这个宏函数的作用很简单:把毫秒值转换为 Tick 数值。

换算公式就是:

c 复制代码
Tick = 毫秒值 × configTICK_RATE_HZ / 1000

如果配置宏configTICK_RATE_HZ的数值就是默认推荐值1000,那么这个宏函数实际没啥意义。

所以:

这个宏函数主要在configTICK_RATE_HZ不是1000时,才用得到,可以更方面的计算出毫秒值对应多少Tick。

但实际上,大多数FreeRTOS工程都会不会修改configTICK_RATE_HZ,所以这个宏函数作用有限,了解即可。

任务挂起状态和挂起列表

在 FreeRTOS 中,任务除了"就绪态""运行态""阻塞态"之外,还有一种特殊状态 ------ 挂起态(Suspended)。

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

  1. 处于任意调度状态的任务,都可以被 vTaskSuspend() 挂起,进入挂起态。(甚至可以挂起后再挂起,但通常没有意义)
  2. 被挂起的任务不参与任务调度,也不会因为条件满足而自行回归就绪态。
  3. 进入挂起态的任务,实质上是被移出原有列表,并插入内核维护的挂起列表xSuspendedTaskList。
  4. 在其他任务中调用 vTaskResume(),就可以恢复某个任务,让它重新被插入就绪列表,回到就绪态。

为什么设计挂起态

既然已经有阻塞态了,为什么还要挂起态?

阻塞态,通常表示任务因为"等待某种条件"而暂时不运行。

挂起态,则表示任务被人为强制停止运行。

阻塞态的恢复依赖条件满足,比如时间到了。

挂起态的恢复不依赖这些条件,而是依赖其他任务或中断主动调用恢复函数。

所以,阻塞更像"任务自己在等事情发生,然后自动恢复",挂起更像"任务被别人按下了暂停键,不会自动恢复"。

vTaskSuspend()和vTaskResume()

想要使用这两个函数,必须开启下面这个宏:

复制代码
#define INCLUDE_vTaskSuspend 1

当然,在配置文件中,它是一个默认配置:

默认开启任务的挂起状态,及其相关的API。

vTaskSuspend()函数的函数原型是:

c 复制代码
void vTaskSuspend(TaskHandle_t xTaskToSuspend);

其中xTaskToSuspend,表示要挂起的任务句柄,如果传参NULL表示挂起当前运行的任务,也就是自己挂起自己。

vTaskResume()函数的函数原型是:

c 复制代码
void vTaskResume(TaskHandle_t xTaskToResume)

其中xTaskToResume,表示要恢复挂起的任务句柄。

这里就不能再传参NULL了,被挂起的任务没有机会执行,不可能自己恢复挂起自己。

参考一个演示代码案例:

c 复制代码
#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 在第一次运行时,会主动调用:

c 复制代码
vTaskSuspend(NULL);

将自己挂起。

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

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

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

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

也就是说:

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

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

c 复制代码
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 中可以找到如下定义:

c 复制代码
PRIVILEGED_DATA static List_t xSuspendedTaskList;

这说明:

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

它的数据类型是:

c 复制代码
List_t

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

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

c 复制代码
/* 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 提供了任务删除接口:

c 复制代码
void vTaskDelete(
    TaskHandle_t xTaskToDelete
);

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

c 复制代码
#define INCLUDE_vTaskDelete 1

参数说明:

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

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

当调用 vTaskDelete 删除任务时:

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

根据官方文档说明:

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

这意味着:

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


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

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

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

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

因此:

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

细节注意事项

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

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

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

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

c 复制代码
void prvIdleTask( void *pvParameters );

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

c 复制代码
prvCheckTasksWaitingTermination()

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

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

如果 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更建议挂起或阻塞它。

代码示例

如果你确实要删除任务,那么更推荐采用以下方式:

c 复制代码
#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. 系统结构更清晰,易维护,符合"调度器管理任务"的设计思路,代码职责分明,后期扩展和排错更容易。

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

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

相关推荐
yume_sibai1 小时前
大屏数据可视化 - 边框红绿呼吸灯实现详解
前端·信息可视化·typescript
冰可乐配1 小时前
fmpeg音频编码组件aac(Advanced Audio Coding (AAC) encoder)学习
学习·音视频·aac
Hyyy2 小时前
很多Desktop都在上的Computer Use是什么
前端·llm
慢功夫2 小时前
开篇:VS Code 为什么不是一个普通 React App
前端·visual studio code
憧憬成为java架构高手的小白2 小时前
MySQl学习
学习
你挚爱的强哥2 小时前
Vue2 实现 1.5s 十连击监听(连续点击若干次),封装通用可复用点击检测工具,不用 data!Vue 封装通用连击监听方法,支持自定义时长与点击次数
前端·javascript·vue.js
程序员张32 小时前
SpringBoot集成BCrypt密码加密库
java·spring boot·后端
闲猫2 小时前
Spring AI Agentic 模式(第1部分):Agent Skills——模块化、可复用的能力
java·人工智能·spring
源图客2 小时前
云途物流API开发-鉴权认证(Java)
java·网络·python
liguojun20253 小时前
篮球馆自动计时收费系统:从规则配置到自动结算的全流程拆解
java·大数据·运维·人工智能·物联网·1024程序员节