FreeRTOS基础篇(三)——任务管理

前言:
   本文将系统性地讲解 FreeRTOS 中任务的创建、运行、状态转换以及阻塞与唤醒机制,帮助读者理解如何利用任务实现复杂的实时应用逻辑。通过实际代码示例,您将掌握任务的基本使用方式,并为后续学习队列、信号量、事件组等高级功能打下坚实基础。让我们从“任务”出发,逐步揭开 FreeRTOS 多任务并发的神秘面纱。

1 介绍

  FreeRTOS的 任务管理 是其核心功能之一,具有轻量、高效、可移植性强等特点。主要具有以下特点。

  • 多任务并发执行

  FreeRTOS支持多任务并法执行(通过时间片轮转和优先级抢占实现),每个任务都是一个独立的函数,拥有自己的 栈空间上下文 环境。值得解释下这里所说的栈空间是什么,它包含函数内部的局部变量、函数调用时的参数信息、函数调用后的返回地址,而上下文是指程序计数器(PC指针)、堆栈指针(SP)、通用寄存器(R0, R1, …)。正是因为每个任务都拥有这两样私有的东西,FreeRTOS才能实现多个任务相互独立、互不干扰地并发执行,从而构建出复杂可靠的嵌入式实时系统。
  FreeRTOS的任务是以函数形式编写的,任务入口的接口形式为 void task_function(void *parameter) ,多个任务共享CPU,由FreeRTOS的内核调度器统一管理调度。多个相同优先级的任务执行时,FreeRTOS采用时间片轮转(Round-Robin)机制实现,每个人轮流执行一个时间片(由系统节拍 tick 决定,默认通常为1ms),公平执行优先级相同的任务。

  • 基于优先级的任务调度

  FreeRTOS的每个任务都有一个 优先级 ,从0到configMAX_PRIORITIES - 1,数值越大优先级越高。高优先级的任务一旦就绪,会立刻抢占当前正在执行的优先级比自己低的任务,因此他是一种实时性的操作系统(软实时)。configMAX_PRIORITIES最大值为256(为8位无符数),因此优先级最多有256级,范围为0~255。由于FreeRTOS的每个任务都有独立的栈空间和上下文存储,因此最大任务数量受限于内存。

  • 任务状态

  FreeRTOS的任务有五种状态。

状态 说明
Running 当前正在运行的任务
Ready 已准备好运行,等待被调度
Blocked 等待事件(如延时、队列、信号量)
Suspended 被显式挂起(暂停执行)
Deleted 已删除(仅在启用任务删除功能时存在)

  调度器会根据状态切换任务,实现高效的资源利用。

TskSta.png

  状态特性对比。

状态 参与调度 占用CPU 资源占用 唤醒方式
运行 CPU资源 -
就绪 内存资源 自动调度
阻塞 内存资源 事件/时间触发
挂起 内存资源 手动恢复
删除 不可恢复

2 任务运用

2.1 创建任务

  创建任务的接口为 xTaskCreate ,有以下几个参数。

1
2
3
4
5
6
7
8
xTaskCreate(
vTask1, // 任务函数
"Task1", // 任务名称(用于调试)
128, // 栈大小(单位:word,通常每个 word=4 字节)
"Task 1", // 传给任务的参数
tskIDLE_PRIORITY + 1, // 优先级(比空闲任务高), tskIDLE_PRIORITY通常为0。
&xTask1Handle // 任务句柄(可选)
);

  其他参数都好理解,重点介绍下任务句柄,可以理解为任务的“遥控器”,任务句柄是一个指向任务控制块(TCB)的指针,拿到任务句柄则可通过句柄管理和操作该任务。比如可以通过任务句柄删除任务、修改任务优先级、挂起任务、恢复任务、任务通知(实现高效的IPC)。
  执行 vTaskStartScheduler 函数后FreeRTOS开始接管任务的调度,正常情况下该函数不会返回,如果程序往后执行,说明调度器运行出现故障,通常是内存不足。以下是一个创建任务的Demo。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include "FreeRTOS.h"
#include "task.h"

/* 任务句柄 */
TaskHandle_t xTask1Handle = NULL;
TaskHandle_t xTask2Handle = NULL;

/* 任务1:每500ms打印一次消息 */
void vTask1(void *pvParameters)
{
while(1)
{
printf("=> %s is running\n", (char*)pvParameters);
vTaskDelay(pdMS_TO_TICKS(500)); // 延迟500毫秒
}
}

void main(void)
{
// 初始化系统时钟、串口等(具体取决于你的硬件平台)

/* 创建两个任务 */
xTaskCreate(
vTask1,
"Task1",
128,
"Task 1",
tskIDLE_PRIORITY + 1,
&xTask1Handle
);

/* 启动调度器,开始执行任务 */
vTaskStartScheduler();

/* 如果程序运行到这里,说明内存不足无法启动调度器 */
while(1)
{
// 调度器启动失败处理
}
}

   vTaskDelay 用于任务延时和调度,使当前任务进入阻塞状态,参数指定了阻塞的时间长度,让出 CPU 给其他就绪态任务。注意,任务是阻塞,CPU并不会阻塞而是去执行其他任务,等到阻塞时间到了之后。FreeRTOS 使用节拍(tick)作为时间单位,通常 1 tick = 1ms(可配置)。

2.2 阻塞任务

  阻塞任务的方式有多种方式,当一个任务进入 Blocked(阻塞)状态 时,它暂时不参与调度,直到某个条件满足(如延时结束、收到信号量、队列有数据等)。这能有效释放 CPU 资源给其他任务。

  • 延迟阻塞

  通过让任务自身调用 vTaskDelay 函数,实现该任务暂停指定时间。

1
2
3
4
5
6
7
8
9
10
void vTask1(void *pvParameters)
{
while(1)
{
printf("Task1: Tick\n");

// 阻塞 1 秒 → 进入 Blocked 状态
vTaskDelay(pdMS_TO_TICKS(1000));
}
}

  注意,阻塞时间是从调度 vTaskDelay 函数开始计算的,所以调度的周期为 Task执行的时间 + 阻塞时间 ,如果当成周期任务使用,则会存在累积误差,当对周期精度要求不高时也可这样使用。

  • 绝对时间阻塞

  通过让任务自身调用 vTaskDelayUntil 函数,实现该任务精确的周期调度。使任务以一个固定的周期执行。它指定一个绝对时间,确保任务以固定的频率执行。第一个参数指向一个变量,该变量保存任务最后一次解除阻塞的时间。函数会根据这个时间和指定的周期来计算下一次唤醒时间,从而保持固定的执行间隔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void vTaskPeriodicExample( void *pvParameters )
{
TickType_t xLastWakeTime;
const TickType_t xFrequency = 100; // 100 tick

// 初始化xLastWakeTime为当前时间
xLastWakeTime = xTaskGetTickCount();

while(1)
{
// 执行一些操作
do_something();

// 等待下一个周期
vTaskDelayUntil(&xLastWakeTime, xFrequency);
}
}
特性 vTaskDelay vTaskDelayUntil
延迟方式 相对延迟,从调用时刻开始算 绝对延迟,固定周期
执行周期 不固定,受任务执行时间影响 固定,不受任务执行时间影响
参数 一个参数:延迟的tick数 两个参数:上次唤醒时间指针和周期
适用场景 不需要精确周期的延迟 需要精确周期执行的任务
  • 等待队列(Queue)数据阻塞

  如果任务从空队列读取数据,默认会自动阻塞,直到有数据或超时(第三个参数是最大等待时间,时间单位是Tick数),如果接收队列时已经有数据,则任务不会阻塞而是继续执行(通常会处理接收到的数据)。
  函数原型为。

1
2
3
BaseType_t xQueueReceive( QueueHandle_t xQueue,
void * const pvBuffer,
TickType_t xTicksToWait ) PRIVILEGED_FUNCTION;

  以下为示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
QueueHandle_t xQueue;

// 创建队列
xQueue = xQueueCreate(5, sizeof(int));

// 在任务中接收数据(带阻塞)
void vReceiverTask(void *pvParameters)
{
int receivedValue;

while(1)
{
// 如果队列为空,任务将阻塞最多 100 ticks
if(xQueueReceive(xQueue, &receivedValue, pdMS_TO_TICKS(100)) == pdTRUE)
{
printf("Received: %d\n", receivedValue);
}
else
{
printf("Timeout: No data received.\n");
}
}
}

  其他任务可以通过发送接口 xQueueSend ,通过同一个队列句柄给接收任务发送数据,函数原型为。

1
2
3
BaseType_t xQueueSend( QueueHandle_t xQueue,
const void * pvItemToQueue,
TickType_t xTicksToWait);

  示例如下。

1
2
3
4
// 发送数据到队列,如果队列满则等待最多100个tick
if(xQueueSend(xQueue, &data, 100) != pdPASS) {
// 发送失败,可能是超时
}

  第一个参数为队列,第二个参数为要传入队列的数据,第三个参数为最大的等待时间。第三个参数是考虑到往队列写入数据时,如果队列满了,最大的等待时间,如果队列满并且还为超过最大阻塞时间,则该任务会阻塞住,指导队列非满写入数据或者超过最大阻塞时间。当最大阻塞时间设为宏 portMAX_DELAY 时(需要配置configUSE_PORT_OPTIMISED_TASK_SELECTION为0,并且FreeRTOSConfig.h中必须定义configUSE_MAX_DELAY为1),表示阻塞时间为无限等待,这个宏的值被定义为全F。

  • 等待信号量阻塞

  信号量也可用于控制任务同步。 xSemaphoreTake 接口会阻塞等待信号量,第一个参数时信号量句柄,第二个参数与队列的方式类似,是最大等待时间(时间单位是Tick数),可以设置为 portMAX_DELAY 表示无限等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SemaphoreHandle_t xBinarySem;

void vTaskWaitForSignal(void *pvParameters)
{
while(1)
{
// 阻塞等待信号量(可能永远等待)
if(xSemaphoreTake(xBinarySem, portMAX_DELAY) == pdTRUE)
{
printf("Got semaphore! Proceeding...\n");
// 处理资源...
}
else
{
printf("Timeout.\n");
}
}
}

  在其他任务中释放信号量。

1
2
// 另一个任务或中断中释放信号量
xSemaphoreGive(xBinarySem); // 唤醒等待任务
  • 手动挂起任务

  通过接口 vTaskSuspend 强制让任务进入 Suspended 状态(不是 Blocked),需显式恢复。
  Suspended 和 Blocked 的区别:

  • Blocked:由内核管理,条件满足后自动恢复。上面提到的任务阻塞方式都是Blocked。
  • Suspended:必须手动调用 vTaskResume() 才能继续
1
2
3
4
vTaskSuspend(xTask1Handle); // 挂起任务1(永久阻塞)

// 恢复任务
vTaskResume(xTask1Handle);

  这里的 xTask1Handle 是任务创建时的任务句柄。