理解 Task——建立基础认知
理解 Task——建立基础认知
1. Task 与普通函数的区别
1.1 普通函数
1.1.1 普通函数的运行方式
普通函数的特点是:
被
main()或其他函数调用后开始执行,执行完成以后返回调用者。
因此,普通函数的生命周期可以表示为:
main() 调用函数
↓
CPU 记录返回位置
↓
执行函数
↓
函数执行 return
↓
CPU 回到原来的调用位置
↓
调用 → 运行 → 返回 → 结束
普通函数只有在被调用时才会运行。
如果程序中没有调用某个函数:
没有函数调用
↓
函数不会执行
因此:
普通函数本身不会主动获得 CPU,必须由其他代码调用以后才能运行。
1.1.2 判断一个函数是不是 Task
一个函数是不是 FreeRTOS Task,不能通过函数名字判断。
例如:
void LED_Task(void)
{
}
仅仅把函数名写成:
LED_Task
并不能说明它就是 FreeRTOS Task。
是否是 Task,主要取决于:
是否由 RTOS 创建
是否拥有独立任务栈
是否拥有 TCB
是否由调度器管理
是否具有任务状态
是否具有任务优先级
不是函数名后面加上
_Task就自动变成了 FreeRTOS Task。
1.2 Task 的基本概念
Task 是:
由 RTOS 内核管理,可以被调度器选择执行的一段独立执行流程。
理解 Task,可以抓住三个核心关键词:
RTOS 内核管理
+
调度器选择
+
独立执行流程
1.2.1 由 RTOS 内核管理
Task 不是简单地在裸机 while(1) 中被反复调用。
Task 创建以后,会交给 FreeRTOS 内核管理。
FreeRTOS 会记录这个 Task 的:
任务状态
任务优先级
任务栈
任务名称
TCB
其他任务管理信息
然后由调度器决定:
这个 Task 什么时候运行。
因此:
Task 被创建
↓
FreeRTOS 记录任务信息
↓
Task 进入内核任务管理体系
↓
等待调度器安排运行
1.2.2 可以被调度器选择
Task 创建成功以后,并不意味着它马上就一定能够运行。
调度器会根据:
任务优先级
当前任务状态
是否正在等待事件
延时是否结束
是否已经具备运行条件
决定:
当前应该让哪个 Task 获得 CPU。
因此:
Task 的运行时机不完全由代码书写顺序决定,而是由 FreeRTOS 调度器决定。
1.2.3 独立执行流程
每个 Task 都拥有自己的:
任务函数
任务状态
任务优先级
独立任务栈
TCB
任务管理信息
因此,一个普通函数被 FreeRTOS 创建成 Task 后,会额外具有:
优先级
状态
独立任务栈
TCB
调度关系
一个 Task 可以先建立下面这个模型:
Task
┌──────────────────┐
│ 任务函数 │
├──────────────────┤
│ 独立任务栈 │
├──────────────────┤
│ 优先级 │
├──────────────────┤
│ 当前状态 │
├──────────────────┤
│ TCB │
└──────────────────┘
Task 不是单纯的一个函数。
1.3 典型 FreeRTOS Task 函数写法
// 包含 FreeRTOS 核心头文件
#include "FreeRTOS.h"
// 包含任务相关 API 声明
#include "task.h"
/**
* @brief LED 任务函数
* @param argument 创建任务时传入的任务参数
*/
void LED_Task(void *argument)
{
// Task 通常通过无限循环长期存在
while (1)
{
// 翻转 LED 当前状态
LED_Toggle();
/*
* 让当前 Task 阻塞 1000 个系统节拍。
* vTaskDelay() 的具体底层逻辑后续详细学习。
*/
vTaskDelay(1000);
}
}
1.4 Task 函数通常没有返回值
典型 Task 函数:
void LED_Task(void *argument)
返回值类型为:
void
普通函数通常:
被调用
↓
运行
↓
return
↓
返回调用者
而 Task 通常是一个长期存在的执行单元。
因此:
Task 一旦创建,通常会长期运行,不会像普通函数一样执行完成后直接
return。
1.5 Task 函数通常接收 void * 参数
典型形式:
void LED_Task(void *argument)
使用:
void *
作为参数类型,是为了让 Task 函数能够接收不同类型的数据。
例如定义任务参数结构体:
// 定义 LED Task 的参数结构体
typedef struct
{
// 保存任务运行周期
uint32_t period_ms;
// 保存需要控制的 LED 编号
uint8_t led_id;
} LED_TaskParam_t;
Task 函数:
// 定义 LED Task
void LED_Task(void *argument)
{
/*
* 将通用 void 指针转换为实际的
* LED_TaskParam_t 参数指针。
*/
LED_TaskParam_t *param = (LED_TaskParam_t *)argument;
// Task 长期运行
while (1)
{
// 根据参数控制指定 LED
LED_ToggleById(param->led_id);
// 根据任务参数决定当前 Task 的运行周期
vTaskDelay(param->period_ms);
}
}
这样在创建 Task 时:
相同 Task 函数
+
不同任务参数
↓
实现不同运行效果
后续学习:
xTaskCreate()
时会实际使用这种方式。
1.6 高频问题
Q1:什么是 FreeRTOS Task?
FreeRTOS Task 是由内核管理和调度的基本执行单元。
每个 Task 由任务函数描述执行逻辑,并拥有自己的任务栈、TCB、优先级和运行状态。
对于单核 MCU,同一时刻只有一个 Task 真正执行,调度器通过任务切换实现多个 Task 的并发运行。
Q2:Task 和普通函数有什么区别?
普通函数由其他函数调用,执行完成后通常返回,并使用当前执行流的栈。
FreeRTOS Task 由内核和调度器管理,拥有独立任务栈、TCB、优先级和任务状态,通常会长期存在,并可以在不同任务状态之间切换。
Q3:FreeRTOS Task 是不是进程?
不是。
FreeRTOS Task 更接近线程。
不同 Task 拥有独立任务栈,但是通常共享同一片地址空间、全局变量和硬件资源,不具备桌面操作系统进程那样完整的地址空间隔离。
Q4:是不是一个功能就应该创建一个 Task?
不是。
Task 的拆分需要综合考虑执行周期、实时性、阻塞特征、数据耦合以及资源开销。
如果多个功能周期相同、关系紧密而且执行时间较短,可以考虑放在同一个 Task 中,避免创建过多 Task 带来的任务栈内存和调度开销。
Q5:FreeRTOS 中的 Task 和普通函数最核心的区别是什么?
普通函数只是当前执行流程中的一段代码,通常被调用、执行并返回。
FreeRTOS Task 除了任务函数以外,还包含独立任务栈、TCB、优先级和任务状态等信息。
FreeRTOS 通过保存和恢复 Task 上下文,使不同 Task 可以被暂停、恢复和调度。
1.7 核心结论
裸机函数主要由程序主动调用,FreeRTOS Task 由内核调度管理。
Task 不是一个“大函数”,而是内核管理的独立执行单元。
Task 主要由任务函数、任务栈、TCB、优先级和状态共同组成。
单核 STM32 同一时刻仍然只能真正执行一个 Task。
2. 为什么 Task 通常不写 return
2.1 普通函数为什么可以 return
普通函数由 main() 或其他函数调用。
例如:
main()
↓ 调用
Add()
↓ return
main()
对于普通函数:
main()
↓
调用 Add()
↓
CPU 记录返回位置
↓
Add() 开始执行
↓
Add() 执行完成
↓
return
↓
回到 main() 中继续执行
CPU 必须知道:
函数执行结束以后应该回到哪里。
在 ARM 架构中,函数调用和返回过程中会涉及:
LR(Link Register)
也就是:
链接寄存器。
2.2 Task 的运行方式不同
Task 不是被普通业务函数调用后执行的。
它由 FreeRTOS 创建并交给内核管理:
Task 被创建
↓
进入内核任务管理体系
↓
等待调度器
↓
调度器选择 Task
↓
Task 开始运行
因此:
普通函数:
调用
↓
运行
↓
返回
↓
结束
而 Task 更接近:
创建
↓
等待调度
↓
运行
↓
阻塞 / 就绪 / 再次运行
↓
最终被删除
所以:
Task 没有一个普通意义上的 C 函数调用者等待它执行完成以后返回。
因此 Task 函数通常不能像普通函数一样直接 return。
3. 为什么 Task 通常写 while(1)
3.1 Task 通常需要长期存在
典型写法:
// 定义 ADC Task
void ADC_Task(void *argument)
{
// Task 长期运行
while (1)
{
// 执行 ADC 数据采集
ADC_Read();
}
}
因为 Task 通常负责:
持续性工作
周期性工作
等待并处理事件
所以需要长期存在。
如果没有:
while (1)
那么程序流程可能变成:
Task 开始运行
↓
执行一次 ADC_Read()
↓
到达函数结尾
Task 就无法继续承担长期工作。
因此:
Task 通常承担持续性或周期性工作,需要长期存在,而不是执行一次以后就结束。
3.2 while(1) 可能带来的任务饥饿
假设 ADC Task 优先级较高:
// 定义 ADC Task
void ADC_Task(void *argument)
{
// ADC Task 永远循环
while (1)
{
// 不断进行 ADC 采集
ADC_Read();
}
}
如果 ADC Task:
一直处于 Ready / Running
+
优先级高
+
循环内部没有阻塞操作
那么它可能持续占用 CPU。
假设还有一个低优先级 OLED Task:
// 定义 OLED Task
void OLED_Task(void *argument)
{
// OLED Task 长期运行
while (1)
{
// 刷新 OLED
OLED_Refresh();
}
}
这时:
OLED Task 可能长期得不到 CPU。
这种现象叫:
任务饥饿(Task Starvation)。
3.3 让 Task 主动暂时放弃 CPU
可以在 Task 中使用阻塞 API。
例如:
// 定义 ADC Task
void ADC_Task(void *argument)
{
// Task 长期运行
while (1)
{
// 执行一次 ADC 数据采集
ADC_Read();
/*
* 当前 Task 延时 10 ms。
* 延时期间 Task 进入阻塞态,不占用 CPU。
*/
vTaskDelay(pdMS_TO_TICKS(10));
}
}
执行过程:
ADC Task 运行
↓
执行 ADC_Read()
↓
调用 vTaskDelay()
↓
ADC Task 进入 Blocked
↓
调度器运行其他 Ready Task
↓
10 ms 到期
↓
ADC Task 重新进入 Ready
↓
等待再次获得 CPU
因此:
while(1)负责让 Task 长期存在,阻塞 API 负责让 Task 在暂时不需要 CPU 时离开运行态。
3.4 高频问题
Q1:为什么 FreeRTOS Task 一般需要死循环?
FreeRTOS Task 通常代表一个长期存在的系统功能。
Task 每完成一轮工作以后,还需要等待下一次调度、下一周期或者下一个事件,因此一般通过
while(1)维持 Task 生命周期。但是循环内部通常还需要调用延时或事件等待等阻塞 API,避免持续占用 CPU。
Q2:Task 中有 while(1) 是否一定会霸占 CPU?
不一定。
是否霸占 CPU,取决于循环内部是否存在阻塞操作。
如果 Task 调用了延时、队列接收或者信号量等待等阻塞接口,就会暂时离开运行态,让其他 Ready Task 获得 CPU。
如果循环内部没有任何阻塞,并且 Task 优先级较高,就可能导致低优先级 Task 饥饿。
Q3:一次性 Task 怎么结束?
一次性 Task 完成工作以后,应该调用任务删除 API 删除自身,而不是像普通函数一样直接返回。
删除自身时,可以向任务删除接口传入
NULL。
4. 创建 Task——xTaskCreate()
4.1 xTaskCreate() 的作用
xTaskCreate() 用于:
创建一个新的 FreeRTOS Task。
相当于告诉 FreeRTOS 内核:
我要创建一个新的独立执行单元
↓
请帮我建立对应的任务管理信息
概念上,创建 Task 会涉及:
1. 创建 TCB
2. 分配任务栈
3. 初始化任务上下文
4. 设置任务优先级
5. 将 Task 加入任务管理列表
5. Task 为什么需要独立任务栈
5.1 普通函数使用当前执行流的栈
普通 C 程序中,不同函数通常位于同一个执行流中。
可以先简单理解为:
RAM
高地址
┌──────────────────┐
│ main 栈 │
│ │
│ LED_Process │
│ │
│ CAN_Process │
└──────────────────┘
低地址
普通函数:
main()
↓
调用函数 A
↓
函数 A 再调用函数 B
这些函数属于同一条函数调用链。
5.2 每个 Task 都拥有独立任务栈
FreeRTOS 中:
Task1 → Task1 Stack
Task2 → Task2 Stack
Task3 → Task3 Stack
RAM 中可以简单理解为:
RAM
┌──────────────────┐
│ Task1 Stack │
├──────────────────┤
│ Task2 Stack │
├──────────────────┤
│ Task3 Stack │
└──────────────────┘
之所以每个 Task 都需要独立任务栈,是因为 Task 可能:
正在运行
↓
突然被切换出去
↓
过一段时间
↓
再次恢复运行
因此必须保存这个 Task 原来的:
局部变量
函数调用关系
返回地址
CPU 上下文
这些运行数据都需要保存在 Task 自己的任务栈中。
如果多个 Task 共用同一块任务栈:
TaskA 写入自己的运行信息
↓
TaskB 又使用同一块栈
↓
TaskA 原来的数据可能被覆盖
↓
TaskA 无法正确恢复
所以:
每个 FreeRTOS Task 都需要拥有自己的独立任务栈。
6. 单核 CPU 如何“同时”运行多个 Task
6.1 STM32F103 是单核 MCU
STM32F103 使用:
ARM Cortex-M3
它只有一个 CPU Core。
因此:
任意一个具体时刻,CPU 都只能真正执行一个 Task。
那么为什么 FreeRTOS 看起来可以同时运行多个 Task?
因为:
CPU 会在不同 Task 之间快速切换。
6.2 单核并发
假设存在两个 Task:
TaskA
TaskB
CPU 可能这样运行:
0ms 2ms 4ms 6ms 8ms
│ │ │ │ │
TaskA TaskB TaskA TaskB TaskA
从较长时间来看:
TaskA 在运行
TaskB 也在运行
但是任意一个具体时刻:
CPU 只能执行一个 Task
这种方式叫:
并发(Concurrency)。
6.3 多核并行
如果 CPU 有两个 Core:
CPU Core0 → TaskA
CPU Core1 → TaskB
那么同一时刻:
TaskA 正在运行
+
TaskB 也正在运行
这种真正同时执行的方式叫:
并行(Parallelism)。
因此:
单核 CPU 快速切换多个 Task
↓
并发 Concurrency
而:
多个 CPU Core 同时执行不同 Task
↓
并行 Parallelism
6.4 FreeRTOS 如何产生“同时运行”的感觉
假设 TaskA 和 TaskB 交替运行:
时间 0~1ms 1~2ms 2~3ms 3~4ms
运行 Task TaskA TaskB TaskA TaskB
那么在 4ms 内:
TaskA 执行了约 2ms
TaskB 执行了约 2ms
但是观察某一个具体时刻:
0.5ms → 只有 TaskA 正在执行
1.5ms → 只有 TaskB 正在执行
因此:
宏观上多个 Task 都在推进,微观上任意时刻仍然只有一个 Task 真正占用单核 CPU。
6.5 调度器的作用
调度器主要负责判断:
现在应该运行哪个 Task?
当前 Task 是否应该让出 CPU?
有没有更高优先级 Task 已经 Ready?
调度器不会自己执行:
ADC 采集
OLED 刷新
CAN 通信
这些业务功能。
它主要负责:
从满足运行条件的 Task 中选择下一个应该获得 CPU 的 Task。
6.6 高频问题
Q1:单核 CPU 能否真正同时运行多个 Task?
不能。
单核 CPU 任意时刻只能执行一个 Task。
FreeRTOS 通过保存当前 Task 上下文、恢复另一个 Task 上下文,在多个 Task 之间快速切换,实现并发执行。
因此从宏观上看起来像多个 Task 同时运行。
7. Task 的基本状态
FreeRTOS Task 常见状态包括:
Running
Ready
Blocked
Suspended
7.1 Running——运行态
Running 表示:
Task 当前正在占用 CPU 执行代码。
对于单核 CPU:
任意具体时刻
↓
通常只有一个 Task
处于 Running
7.2 Ready——就绪态
Ready 表示:
Task 已经具备运行条件,但是正在等待 CPU。
可以简单理解为:
Task 已经准备好了
↓
只差获得 CPU
7.3 Blocked——阻塞态
Blocked 表示:
Task 正在等待某个条件,因此暂时不参与 CPU 竞争。
例如等待:
延时结束
事件发生
消息到达
信号量
7.4 Suspended——挂起态
Suspended 表示:
Task 被显式挂起,不会因为普通时间条件或者事件条件自动恢复,需要显式恢复。
7.5 vTaskDelay() 和裸机延时有什么区别?
裸机忙等待延时通常会持续占用 CPU。
vTaskDelay()会让当前 Task 进入 Blocked 状态。在延时期间,当前 Task 不参与 CPU 竞争,因此 CPU 可以运行其他 Ready Task。
7.6 Task 阻塞以后 CPU 会停止吗?
不会。
当前 Task 进入 Blocked 后,调度器会选择其他 Ready Task 运行。
如果没有其他用户 Task 可以运行,FreeRTOS 会运行 Idle Task。
8. Task 在内存中的基本结构
8.1 一个完整 Task 由什么组成
一个完整的 FreeRTOS Task 可以理解为:
任务函数
+
任务栈
+
TCB
+
调度器中的任务列表节点
其中最重要的三个部分是:
任务函数 + 任务栈 + TCB
8.2 任务函数
任务函数负责:
规定这个 Task 要执行什么业务逻辑。
例如:
ADC_Task
↓
执行 ADC 数据采集
CAN_Task
↓
执行 CAN 通信
OLED_Task
↓
执行 OLED 显示
所以:
任务函数决定“Task 要做什么”。
8.3 任务栈
普通 C 程序中的栈通常用于保存:
局部变量
函数参数
函数返回地址
被压栈保存的 CPU 寄存器
函数调用关系
FreeRTOS 中,每个 Task 都拥有自己的独立任务栈:
STM32 RAM
高地址 ┌────────────────────────┐
│ A_Task 独立任务栈 │
│ │
│ 局部变量 │
│ 函数调用信息 │
│ 保存的寄存器等 │
├────────────────────────┤
│ B_Task 独立任务栈 │
│ │
│ 局部变量 │
│ 函数调用信息 │
│ 保存的寄存器等 │
├────────────────────────┤
│ C_Task 独立任务栈 │
│ │
│ 局部变量 │
│ 函数调用信息 │
│ 保存的寄存器等 │
├────────────────────────┤
│ 全局变量、静态变量等 │
低地址 └────────────────────────┘
所以:
任务栈负责保存“Task 当前运行成什么样”。
9. TCB——任务控制块
9.1 TCB 是什么
TCB 全称:
Task Control Block
中文:
任务控制块。
可以把 TCB 理解成:
FreeRTOS 为每个 Task 建立的一份“任务档案”。
TCB 会记录:
当前任务栈顶位置
任务优先级
任务名称
任务列表节点
其他任务管理信息
9.2 TCB 中非常关键的信息——任务栈顶指针
为了帮助理解,可以建立一个简化 TCB:
/*
* 这是为了帮助理解而写的简化 TCB。
* 并不是 FreeRTOS 源码中的完整定义。
*/
typedef struct
{
// 保存该 Task 当前任务栈顶位置
uint32_t *pxTopOfStack;
// 保存 Task 优先级
uint32_t uxPriority;
// 保存 Task 名称
char pcTaskName[16];
// 概念上表示 Task 当前状态
uint32_t taskState;
} SimpleTCB;
其中从任务切换角度看,一个非常关键的成员是:
uint32_t *pxTopOfStack;
它记录的是:
这个 Task 当前任务栈保存到了什么位置。
9.3 为什么只记录栈顶位置就非常重要
假设 LED Task 正在运行。
任务被切换出去时,相关上下文需要保存在自己的任务栈中。
例如:
LED Task Stack
┌────────────────────┐
│ 原有局部变量 │
├────────────────────┤
│ 函数调用相关信息 │
├────────────────────┤
│ 保存的 CPU 寄存器 │
├────────────────────┤
│ …… │
├────────────────────┤
│ 最后保存的数据 │ ← 当前任务栈顶
└────────────────────┘
TCB 记录:
pxTopOfStack
=
LED Task 当前任务栈顶地址
以后恢复 LED Task 时:
读取 LED Task 的 TCB
↓
读取 pxTopOfStack
↓
找到 LED Task 对应任务栈位置
↓
恢复保存的 CPU 上下文
↓
LED Task 继续运行
因此:
TCB 中的任务栈顶指针,是 Task 上下文保存与恢复之间非常关键的连接点。
10. 任务函数、任务栈和 TCB 的关系
三者可以这样理解:
任务函数
↓
规定“Task 要做什么”
↓
保存业务代码
任务栈
↓
保存“Task 当前运行成什么样”
↓
局部变量
函数调用信息
CPU 上下文
TCB
↓
记录“这个 Task 怎么管理”
↓
任务栈顶位置
优先级
名称
列表节点
其他任务管理信息
整体关系:
Task
┌──────────────────────┐
│ 任务函数 │
│ │
│ 决定 Task 执行什么 │
└──────────┬───────────┘
│
│ 运行时使用
↓
┌──────────────────────┐
│ 独立任务栈 │
│ │
│ 局部变量 │
│ 函数调用信息 │
│ CPU 上下文 │
└──────────┬───────────┘
↑
│ 记录任务栈位置
│
┌──────────┴───────────┐
│ TCB │
│ │
│ 任务栈顶指针 │
│ 优先级 │
│ 名称 │
│ 列表节点等 │
└──────────────────────┘
11. 任务栈和 TCB 是什么时候创建的
11.1 Task 创建的基本流程
Task 创建时,会建立对应的 TCB 和任务栈。
概念流程:
提供任务函数
提供任务名称
提供任务栈深度
提供任务参数
提供任务优先级
↓
内核分配或使用一块 TCB 内存
↓
内核分配或使用一块任务栈内存
↓
在任务栈中构造 Task 初始上下文
↓
TCB 记录任务栈顶位置
↓
Task 加入任务管理列表
11.2 动态创建
动态创建时:
TCB 和任务栈通常由 FreeRTOS 从 Heap 中动态分配。
11.3 静态创建
静态创建时:
TCB 和任务栈所需要的内存由用户提前提供。
12. Task 相关内容在内存中的位置
可以先建立下面这个基本认识:
Flash:
任务函数代码
FreeRTOS 内核代码
常量数据
RAM:
任务栈
TCB
全局变量
静态变量
FreeRTOS Heap
其中:
Flash 主要保存程序代码以及适合存放在非易失性存储中的数据。
RAM 主要保存程序运行过程中需要频繁读写的数据。
13. 高频面试问题
13.1 一个 FreeRTOS Task 由哪些部分组成?
一个 FreeRTOS Task 不仅包括任务函数,还包括独立任务栈和任务控制块 TCB。
任务函数描述业务逻辑;
任务栈保存局部变量、函数调用信息以及任务切换相关的 CPU 上下文;
TCB 保存任务栈顶指针、优先级、任务名称和列表节点等任务管理信息。
13.2 为什么每个 Task 必须拥有独立任务栈?
因为 Task 可能在运行过程中被暂停或者切换出去,恢复时需要保留自己原来的局部变量、函数调用信息以及 CPU 上下文。
如果多个 Task 共用同一块任务栈,不同 Task 的运行数据可能互相覆盖,从而无法正确恢复。
13.3 TCB 中非常关键的信息是什么?
从任务上下文切换角度来看,TCB 中的任务栈顶指针非常关键。
它记录当前 Task 对应任务栈的关键位置,是 Task 上下文保存和恢复的重要入口。
Task 被切换出去以后,相关上下文保存在自己的任务栈中;恢复 Task 时,再根据 TCB 中记录的任务栈信息恢复上下文。
13.4 两个 Task 能否使用同一个任务函数?
可以。
两个 Task 可以使用完全相同的任务函数,但是它们拥有不同的 TCB、独立任务栈、任务参数和运行状态,因此仍然是两个不同的 Task 对象。
更多推荐
所有评论(0)