任务与任务状态:任务的概念、任务状态机、任务控制块与任务栈
大家好,我是你们的老朋友。今天咱们来聊聊 RTOS 里最核心的东西——任务。说白了,整个 RTOS 就是围着任务转的。你想想看,没有任务,调度器调度谁去?
我刚开始接触 RTOS 那会儿,总觉得任务就是个“死循环函数”。后来踩了不少坑才明白,任务远不止这么简单。它有自己的生命周期、自己的“身份证”、自己的“小仓库”。这一章,咱们就把这些掰开揉碎了讲清楚。
2.1 任务的概念
在裸机编程里,我们写一个 main() 里的超级循环,把所有功能按顺序排好。但 RTOS 不一样,它把整个系统拆成一个个独立的任务(Task)。每个任务负责一件具体的事,比如:
- 任务 A:读取温度传感器数据
- 任务 B:刷新 OLED 屏幕
- 任务 C:处理按键输入
- 任务 D:通过 Wi-Fi 上报数据
这些任务看起来是“同时”运行的,实际上 CPU 在它们之间快速切换。这就是所谓的“并发”。
我记得有个学员问我:“那任务和函数有什么区别?” 区别大了。函数是被调用的,调用完就返回了。任务是个无限循环,它不返回。一旦返回,任务就结束了,RTOS 会把它清理掉。
2.2 任务状态机
任务不是一直都能运行的。它有好几种状态,在状态之间跳来跳去。这就是任务状态机。我习惯把它画成一张图,这样好理解。
这张图我建议你多看几遍。四种状态之间的转换,是理解 RTOS 调度的基础。
2.2.1 就绪态(Ready)
任务已经准备好了,随时可以运行。但 CPU 正被别的任务占着,所以它在等。调度器会从就绪列表里挑一个优先级最高的来运行。
2.2.2 运行态(Running)
任务正在使用 CPU。在单核芯片上,同一时刻只有一个任务处于运行态。嗯,这里要注意:如果任务自己调用了 taskYIELD() 或者时间片到了,它就会回到就绪态。
2.2.3 阻塞态(Blocked)
任务在等某件事发生。比如等一个信号量、等队列里有数据、或者调用 vTaskDelay() 延时。这时候任务不消耗 CPU,调度器会去运行别的任务。
while(1) 空转等待。千万别!那会浪费 CPU。正确的做法是让任务进入阻塞态,等事件来了再唤醒。我曾经在一个项目中,把几个空转的 while 改成阻塞等待后,CPU 占用率从 85% 降到了 15%。
2.2.4 挂起态(Suspended)
任务被主动暂停了。调用 vTaskSuspend() 可以挂起一个任务,调用 vTaskResume() 可以恢复它。挂起态的任务不参与调度,直到被显式恢复。
2.3 任务控制块(TCB)
每个任务在 RTOS 内核里都有一个“身份证”,这就是任务控制块(Task Control Block,TCB)。TCB 是一个结构体,里面存着任务的所有信息。
我习惯把 TCB 想象成任务的“档案袋”。调度器每次切换任务,都要查这个档案袋。
下面是一个典型的 TCB 结构(以 FreeRTOS 为例):
typedef struct tskTaskControlBlock {
volatile StackType_t *pxTopOfStack; // 栈顶指针
ListItem_t xStateListItem; // 状态列表项
ListItem_t xEventListItem; // 事件列表项
UBaseType_t uxPriority; // 任务优先级
StackType_t *pxStack; // 栈起始地址
char pcTaskName[configMAX_TASK_NAME_LEN]; // 任务名称
// ... 还有其他成员
} tskTCB;
关键字段说明:
| 字段 | 作用 | 我的理解 |
|---|---|---|
pxTopOfStack |
保存任务当前的栈顶指针 | 切换任务时,CPU 寄存器就存在这里 |
xStateListItem |
把任务挂到就绪/阻塞/挂起列表里 | 调度器靠这个找到任务 |
uxPriority |
任务的优先级(0 最低) | 数值越大,优先级越高 |
pxStack |
指向任务栈的起始地址 | 栈溢出检查时要用到 |
pcTaskName |
任务的名字,方便调试 | 我习惯用有意义的名称,比如 "LED_Task" |
2.4 任务栈
每个任务都有自己的栈空间。这个栈用来保存:
- 局部变量
- 函数调用时的返回地址
- 任务切换时的 CPU 寄存器(压栈保存)
- 中断嵌套时的上下文
任务栈的大小怎么定?这是个好问题。定大了浪费 RAM,定小了会栈溢出。我一般这样估算:
- 先看任务里最大嵌套的函数调用深度
- 每个函数调用大概占 20-40 字节(看架构)
- 加上局部变量的大小
- 再加上中断嵌套的余量(通常 100-200 字节)
- 最后乘以 1.5 的安全系数
举个例子:
// 假设一个任务只有简单的 LED 闪烁
void vLEDTask(void *pvParameters) {
while(1) {
GPIO_Toggle(LED_PIN);
vTaskDelay(pdMS_TO_TICKS(500));
}
}
// 这个任务栈给 128 字节就够了
// 但为了安全,我通常给 256 字节
uxTaskGetStackHighWaterMark(),可以告诉你任务栈还剩多少没用。我习惯在产品开发阶段,让所有任务都跑一遍最极端的场景,然后看这个水位值,再调整栈大小。
任务栈和 TCB 的关系很紧密。TCB 里存着栈的起始地址和当前栈顶指针。任务切换时,CPU 先把当前寄存器的值压入当前任务的栈,然后从 TCB 里取出新任务的栈顶指针,恢复寄存器,最后跳转到新任务继续执行。
嗯,这个过程叫“上下文切换”。它是 RTOS 调度的核心动作。后面我们会专门讲它的实现细节。
最后说一句:任务栈是 RAM 里最容易被忽视的“隐形杀手”。栈溢出不会立刻报错,而是悄悄地破坏相邻的内存。等系统崩溃了,你都不知道问题出在哪。所以,一定要给每个任务留够栈空间,并且开启栈溢出检测。