3、任务创建与删除:任务创建API(如xTaskCreate)、任务删除API、任务参数传递
好,咱们进入第三个核心知识点。任务创建与删除,说白了就是RTOS里的「生老病死」。一个嵌入式系统,说白了就是一堆任务在抢CPU时间片。怎么让任务「生」出来?怎么让它「死」掉?参数怎么传进去?这些搞不清楚,程序跑起来就是一团乱麻。
我记得刚接触FreeRTOS那会儿,觉得xTaskCreate不就是个函数嘛,调一下就行了。结果呢?任务栈设太小,跑着跑着就崩了。参数传了个局部变量指针,任务还没启动,变量先被回收了……嗯,这些坑我今天都给你指出来。
3.1 任务创建API:xTaskCreate
任务创建,最核心的就是xTaskCreate这个函数。你想想看,操作系统要管理一个任务,它得知道什么?任务函数入口、任务名字、栈大小、参数、优先级、句柄。这些信息,全得通过xTaskCreate告诉内核。
函数原型长这样:
BaseType_t xTaskCreate(
TaskFunction_t pvTaskCode, // 任务函数指针
const char * const pcName, // 任务名称(调试用)
configSTACK_DEPTH_TYPE usStackDepth, // 栈深度(单位是字,不是字节)
void *pvParameters, // 传递给任务的参数
UBaseType_t uxPriority, // 任务优先级
TaskHandle_t *pxCreatedTask // 返回的任务句柄
);
这里我要特别强调一点:栈深度单位是字。在32位处理器上,一个字是4字节。如果你设了128,实际栈大小是512字节。我见过太多人把这个搞错,栈设太小,任务一跑就栈溢出,系统直接挂掉。
返回值说明:
- pdPASS:任务创建成功
- errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY:内存不足,创建失败
举个实际例子。我在做一个继电保护装置时,需要创建一个数据采集任务:
TaskHandle_t xDataAcqHandle = NULL;
void vDataAcquisitionTask(void *pvParameters)
{
// 参数解析
uint32_t ulSampleRate = *(uint32_t *)pvParameters;
for(;;)
{
// 采集模拟量数据
vTaskDelay(pdMS_TO_TICKS(1000 / ulSampleRate));
}
}
void vCreateTasks(void)
{
uint32_t ulRate = 4000; // 4kHz采样率
if(xTaskCreate(
vDataAcquisitionTask, // 任务函数
"DataAcq", // 任务名
256, // 栈深度256字 = 1024字节
(void *)&ulRate, // 传递采样率参数
3, // 优先级3
&xDataAcqHandle // 保存句柄
) != pdPASS)
{
// 创建失败处理
// 我一般会在这里记录错误日志
}
}
个人经验:任务栈大小怎么定?我习惯先给个保守值,比如256字。然后用uxTaskGetStackHighWaterMark()函数在任务运行稳定后查看实际使用峰值,再调整到合适大小。别一开始就给太大,浪费RAM;也别太小,容易崩。
3.2 任务参数传递
参数传递这块,看着简单,其实坑不少。xTaskCreate的pvParameters参数是个void指针,你可以传任何东西进去。但这里有个关键问题:参数的生命周期。
我曾经犯过一个低级错误:
void vBadExample(void)
{
uint32_t ulLocalVar = 100;
xTaskCreate(vTask, "Task", 128, &ulLocalVar, 1, NULL);
// 函数返回后,ulLocalVar被销毁!
// 但任务可能还没开始运行,或者正在使用这个地址
}
为什么会这样?因为任务创建后,不一定立即执行。它可能被调度器挂起,等一会儿才拿到CPU。到那时候,ulLocalVar所在的栈空间已经被其他函数覆盖了。你想想看,任务拿到的参数值是什么?鬼知道。
正确的做法有两种:
- 传静态变量或全局变量——生命周期贯穿整个程序运行
- 传堆上分配的内存——用pvPortMalloc分配,任务用完再释放
我个人更推荐第一种,简单可靠:
static uint32_t ulTaskParam = 100; // 静态变量,生命周期长
void vGoodExample(void)
{
xTaskCreate(vTask, "Task", 128, (void *)&ulTaskParam, 1, NULL);
}
如果参数是结构体,也可以传结构体指针。但记住,结构体也必须是静态的或者堆分配的。
3.3 任务删除API:vTaskDelete
有生就有死。任务删除用vTaskDelete。这个函数很简单,但用不好也会出问题。
void vTaskDelete(TaskHandle_t xTaskToDelete);
参数传NULL,表示删除自己。传有效句柄,删除指定任务。
重要警告:
- 任务删除后,它占用的栈和TCB会被释放回堆
- 但任务持有的互斥量、信号量等资源不会自动释放
- 如果任务有动态分配的内存,删除前必须自己释放
我遇到过这样一个案例:一个通信任务,里面用pvPortMalloc分配了缓冲区。任务退出时直接调了vTaskDelete(NULL),结果缓冲区泄漏了。系统跑了几天,堆内存耗尽,所有任务都创建不出来了。
正确的做法是:
void vCommunicationTask(void *pvParameters)
{
uint8_t *pucBuffer = (uint8_t *)pvPortMalloc(256);
if(pucBuffer == NULL)
{
vTaskDelete(NULL); // 内存分配失败,直接退出
}
for(;;)
{
// 正常通信逻辑
}
// 任务要删除了,先释放资源
vPortFree(pucBuffer);
vTaskDelete(NULL);
}
3.4 任务删除的时机问题
这里有个细节:任务删除后,句柄还有效吗?
答案是:无效。任务被删除后,它的TCB被回收了,句柄指向的内存已经不属于你了。如果你还拿着这个句柄去操作任务,比如挂起、恢复、改优先级,那结果就是——未定义行为。轻则程序跑飞,重则系统崩溃。
我建议的做法是:删除任务后,立即把句柄置为NULL:
if(xDataAcqHandle != NULL)
{
vTaskDelete(xDataAcqHandle);
xDataAcqHandle = NULL; // 防止野指针
}
避坑指南:我曾经在一个项目中,用定时器回调函数里删除了一个任务。结果呢?任务正在运行时被删了,它占用的资源还没释放完,系统直接hard fault。记住:不要在中断或回调中删除任务,除非你非常清楚自己在做什么。最好在任务自己的代码里,或者通过消息队列通知任务自己退出。
3.5 任务创建与删除的典型应用场景
在实际的保护装置中,任务创建和删除常用于以下场景:
| 场景 | 说明 | 注意事项 |
|---|---|---|
| 系统初始化 | 创建所有常驻任务 | 确保栈大小合理,优先级正确 |
| 动态任务 | 根据外部事件临时创建任务 | 注意内存碎片,用完即删 |
| 故障恢复 | 删除异常任务,重新创建 | 先释放资源,再删任务 |
| 低功耗模式 | 删除非必要任务,降低功耗 | 恢复时重新创建,注意状态保存 |
嗯,任务创建与删除,说白了就是RTOS里的「内存管理」加「生命周期管理」。你只要记住三点:参数生命周期要长、删除前释放资源、句柄置空防野指针。这三点做到了,任务管理这块基本不会出大问题。
下一节,咱们聊聊任务的状态与调度。任务创建好了,它怎么跑?怎么停?怎么切换?这些才是RTOS的精髓所在。