第三章 任务管理与调度:让多个任务有序运行
任务管理,说白了就是RTOS的「交警」。它决定哪个任务先跑、哪个任务等着、哪个任务该睡觉。我刚开始接触RTOS时,总觉得任务调度不就是个循环吗?后来在项目中吃了亏才明白——调度策略选错了,系统可能直接崩掉。
这一章,我们重点聊三个东西:任务状态机、任务创建/删除/挂起/恢复、调度策略。嗯,都是实战中天天要用的。
3.1 任务状态机:任务的一生
每个任务在RTOS里都有生命周期。不是简单的「跑或不跑」,而是有多个状态。我习惯把任务状态机画成一张图,贴在工位上。你想想看,一个任务从创建到销毁,会经历哪些状态?
核心状态(以FreeRTOS为例):
- 就绪(Ready):任务已就绪,只等CPU分配时间片
- 运行(Running):任务正在占用CPU
- 阻塞(Blocked):任务在等待某个事件(延时、信号量、队列等)
- 挂起(Suspended):任务被显式挂起,不参与调度
我的经验:调试任务卡死时,先查任务状态。如果任务一直处于就绪但不运行,大概率是优先级问题;如果任务卡在阻塞状态,检查它等的事件有没有人发。
3.2 任务创建与删除
任务创建是RTOS的起点。每个任务都需要一个独立的栈空间和任务控制块(TCB)。我见过不少新手把栈开得特别大,结果内存不够用。其实栈大小要根据任务的实际调用深度来算。
3.2.1 任务创建
// FreeRTOS 任务创建示例
void vTaskFunction(void *pvParameters) {
// 任务主体,通常是一个死循环
for (;;) {
// 做任务该做的事
vTaskDelay(pdMS_TO_TICKS(100)); // 延时100ms
}
}
void main(void) {
TaskHandle_t xTaskHandle = NULL;
// 创建任务
xTaskCreate(
vTaskFunction, // 任务函数指针
"Task1", // 任务名称(调试用)
256, // 栈深度(单位:字,不是字节)
NULL, // 任务参数
2, // 优先级(数值越大优先级越高)
&xTaskHandle // 任务句柄(可选)
);
// 启动调度器
vTaskStartScheduler();
}
注意:任务函数绝对不能返回!如果函数执行完毕,系统会调用vTaskDelete(NULL)自行删除任务。但如果你在任务里用了return,后果很严重——系统会崩溃。我曾经在一个项目里犯过这个错,排查了两天才找到原因。
3.2.2 任务删除
任务删除有两种场景:任务自己删除自己,或者别的任务删除它。我建议尽量让任务自己结束生命周期,这样更安全。
// 任务自己删除自己
void vTaskFunction(void *pvParameters) {
for (;;) {
if (/* 任务完成条件 */) {
vTaskDelete(NULL); // 传入NULL表示删除自己
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
// 其他任务删除此任务
void vOtherTask(void *pvParameters) {
// 假设xTaskHandle是全局的
if (xTaskHandle != NULL) {
vTaskDelete(xTaskHandle);
xTaskHandle = NULL; // 记得置空,防止野指针
}
}
避坑指南:删除任务后,任务占用的栈空间会被回收,但任务申请的动态内存(如malloc)不会自动释放。我曾经在删除任务前忘了释放一块缓冲区,结果内存泄漏,系统跑了三天后挂了。所以,删除前记得清理资源。
3.3 任务挂起与恢复
挂起和阻塞不一样。阻塞是任务主动等待事件,挂起是外部强制让任务「睡觉」。挂起的任务不参与调度,直到被显式恢复。
| 操作 | API函数 | 说明 |
|---|---|---|
| 挂起任务 | vTaskSuspend(TaskHandle_t) | 任务进入挂起状态,不参与调度 |
| 恢复任务 | vTaskResume(TaskHandle_t) | 任务从挂起状态回到就绪状态 |
| 从中断恢复 | xTaskResumeFromISR(TaskHandle_t) | 在中断服务函数中恢复任务 |
// 挂起与恢复示例
TaskHandle_t xSensorTaskHandle = NULL;
void vSensorTask(void *pvParameters) {
for (;;) {
// 读取传感器数据
uint32_t sensor_val = read_sensor();
process_data(sensor_val);
vTaskDelay(pdMS_TO_TICKS(50));
}
}
void vControlTask(void *pvParameters) {
for (;;) {
// 某些条件下挂起传感器任务
if (/* 省电模式 */) {
vTaskSuspend(xSensorTaskHandle);
}
// 条件满足时恢复
if (/* 退出省电模式 */) {
vTaskResume(xSensorTaskHandle);
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
关键区别:
- 阻塞:任务自己调用延时或等待事件,可以设置超时
- 挂起:外部强制暂停任务,没有超时机制,必须显式恢复
3.4 调度策略:谁先跑?跑多久?
调度策略是RTOS的核心。选错了,系统响应慢;选对了,任务切换丝滑。我见过有人把优先级设得乱七八糟,结果高优先级任务一直占着CPU,低优先级任务饿死了。
3.4.1 优先级抢占调度
这是最常用的调度策略。每个任务有一个优先级,数值越大优先级越高。调度器永远运行当前就绪态中优先级最高的任务。
举个例子:
// 三个任务,优先级不同
void vHighTask(void *pvParameters) { // 优先级 3
for (;;) {
// 高优先级任务
vTaskDelay(pdMS_TO_TICKS(100));
}
}
void vMidTask(void *pvParameters) { // 优先级 2
for (;;) {
// 中等优先级任务
vTaskDelay(pdMS_TO_TICKS(200));
}
}
void vLowTask(void *pvParameters) { // 优先级 1
for (;;) {
// 低优先级任务
vTaskDelay(pdMS_TO_TICKS(500));
}
}
运行逻辑是这样的:
- 三个任务都就绪时,vHighTask先跑
- vHighTask阻塞(延时)后,vMidTask跑
- vMidTask阻塞后,vLowTask跑
- vHighTask延时结束,立即抢占当前任务
优先级反转:这是抢占式调度的经典问题。低优先级任务持有高优先级任务需要的资源,导致高优先级任务被阻塞。我曾经在一个电机控制项目里遇到这个问题,电机响应延迟了200ms,差点把设备烧了。解决方案是优先级继承或使用互斥量(Mutex)。
3.4.2 时间片轮转调度
同优先级任务之间,用时间片轮转。每个任务跑一个固定时间片(通常是1-10ms),时间到了就切换到下一个同优先级任务。
| 特性 | 优先级抢占 | 时间片轮转 |
|---|---|---|
| 适用场景 | 任务优先级差异明显 | 同优先级任务需要公平调度 |
| CPU分配 | 高优先级任务优先 | 同优先级任务均分时间 |
| 实时性 | 高(响应快) | 中等(取决于时间片大小) |
| 典型配置 | configUSE_PREEMPTION = 1 | configUSE_TIME_SLICING = 1 |
// FreeRTOSConfig.h 中的调度配置
#define configUSE_PREEMPTION 1 // 启用抢占式调度
#define configUSE_TIME_SLICING 1 // 启用时间片轮转
#define configUSE_PORT_OPTIMISED_TASK_SELECTION 1 // 硬件优化任务选择
我的建议:大多数嵌入式系统用优先级抢占就够了。时间片轮转适合那些没有明显优先级差异、但都需要公平CPU时间的任务。比如,多个数据采集任务,优先级相同,用时间片轮转让它们轮流采集。
3.5 实战中的调度策略选择
选调度策略,说白了就是回答两个问题:
- 任务之间有没有明确的优先级关系?有 -> 用抢占式
- 同优先级任务需要公平调度吗?需要 -> 开启时间片轮转
我做过一个物联网网关项目,里面有三种任务:
- 通信任务(优先级5):处理网络数据,要求低延迟
- 传感器任务(优先级3):采集数据,可以容忍一定延迟
- 日志任务(优先级1):写SD卡,不着急
这种场景,抢占式调度是唯一选择。通信任务必须优先响应,否则丢包。日志任务优先级最低,有空再写。
总结一下调度策略的核心原则:
- 实时性要求高的任务,给高优先级
- 同优先级任务,用时间片轮转保证公平
- 避免优先级反转,用互斥量或优先级继承
- 不要把所有任务都设成同一个优先级——那就成裸机轮询了
嗯,任务管理和调度这块,说难不难,说简单也不简单。关键是多动手、多调试。我刚开始用RTOS时,光任务状态就调了好几个晚上。但一旦理解了状态机的流转逻辑,后面就顺了。