3. 内存管理基础

各位同学,今天我们来聊聊RTOS里最基础、也最核心的操作——任务的创建与删除。说白了,就是怎么让一个任务“活起来”,又怎么让它“安全地走”。

我刚开始用FreeRTOS时,觉得xTaskCreate不就是个函数嘛,填几个参数就完事了。结果呢?有一次产品在现场跑了两天,突然死机。查了三天,发现是一个任务创建后忘了保存句柄,导致后续无法删除,内存泄漏把堆耗光了。从那以后,我对任务创建和删除的每一个细节都不敢马虎。

3.1 xTaskCreate:任务的“出生证明”

每个任务在RTOS里都是一个独立的执行流。创建任务,就是给这个执行流分配栈空间、任务控制块(TCB),并把它加入就绪队列。xTaskCreate就是干这个活的。

它的原型长这样:

BaseType_t xTaskCreate(
    TaskFunction_t pvTaskCode,      // 任务函数指针
    const char * const pcName,      // 任务名称(调试用)
    configSTACK_DEPTH_TYPE usStackDepth, // 栈深度,单位是字(不是字节!)
    void *pvParameters,             // 传递给任务函数的参数
    UBaseType_t uxPriority,         // 任务优先级
    TaskHandle_t *pxCreatedTask     // 返回的任务句柄
);

返回值:pdPASS表示成功,errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY表示堆空间不够。

⚠️ 常见陷阱:栈深度单位

usStackDepth的单位是字,不是字节。在32位MCU上,一个字是4字节。如果你要分配1024字节的栈,usStackDepth应该填256,而不是1024。我曾经见过一个同事填了1024,结果栈空间被分配了4KB,把整个堆都吃掉了。

3.2 任务参数传递:不只是传个数字

pvParameters是一个void*指针,你可以用它传递任何东西。我习惯用它传递结构体指针,这样任务函数可以访问多个参数。

看个例子:

typedef struct {
    uint8_t  uart_id;
    uint32_t baud_rate;
    uint8_t  parity;
} uart_config_t;

void uart_task(void *pvParameters) {
    uart_config_t *config = (uart_config_t *)pvParameters;
    // 使用 config->uart_id, config->baud_rate, config->parity
    // ...
    vTaskDelete(NULL);  // 任务自我终止
}

void app_main(void) {
    uart_config_t config = {
        .uart_id = 1,
        .baud_rate = 115200,
        .parity = 0
    };
    TaskHandle_t task_handle = NULL;
    xTaskCreate(uart_task, "uart", 256, &config, 5, &task_handle);
}
💡 我的经验:参数的生命周期

注意!上面这个例子有个隐患——config是局部变量,如果app_main在任务使用config之前就返回了,config的内存会被释放,任务拿到的就是野指针。我一般用两种方式解决:

  • 把config声明为static
  • 或者用pvPortMalloc在堆上分配,任务结束时再free

3.3 任务删除:优雅地退场

任务删除有两种场景:

  • 自我终止:任务内部调用vTaskDelete(NULL)
  • 外部删除:其他任务或中断调用vTaskDelete(handle)

vTaskDelete不会立即释放任务占用的内存。它只是把任务从所有调度队列中移除,然后让空闲任务(Idle Task)去清理TCB和栈空间。所以,删除任务后,空闲任务必须有机会运行,否则内存永远收不回来。

🔑 关键点:删除后的行为

任务被删除后,它的句柄就失效了。如果你还拿着这个句柄去操作(比如挂起、恢复),系统会崩溃。我习惯在删除后立即把句柄置为NULL:

vTaskDelete(task_handle);
task_handle = NULL;  // 防止悬空指针

3.4 任务句柄管理:你的“遥控器”

TaskHandle_t本质上是一个指向TCB的指针。有了它,你可以在任务外部控制任务的挂起、恢复、删除、优先级调整等。

句柄的获取方式:

  1. 创建时获取:xTaskCreate的最后一个参数
  2. 运行时获取:xTaskGetHandle("task_name"),通过任务名查找
  3. 获取自身句柄:xTaskGetCurrentTaskHandle()

我个人的习惯是:创建任务时一定保存句柄,除非这个任务永远不需要被外部控制。你想想看,如果后续要加功能,没有句柄就得重构代码,多麻烦。

3.5 知识结构总览

下面这张图把任务创建与删除的核心流程串起来了:

任务创建与删除核心流程 xTaskCreate 调用 分配TCB + 栈空间 (堆内存) 加入就绪队列 (调度器可见) 任务运行中:通过 pvParameters 访问传入数据 自我终止 vTaskDelete(NULL) 外部删除 vTaskDelete(handle) 空闲任务清理 释放TCB + 栈内存 ⚠️ 关键: 删除后句柄立即失效,需置NULL;空闲任务必须有机会运行才能完成内存回收。

3.6 避坑指南:我踩过的那些坑

场景 问题 解决方案
栈溢出 任务栈深度不够,导致栈溢出,系统随机崩溃 使用uxTaskGetStackHighWaterMark()监控栈余量,留出20%余量
句柄悬空 任务已删除,但其他代码仍用旧句柄操作 删除后立即置NULL,操作前检查句柄是否为NULL
参数生命周期 传递局部变量地址,任务使用时变量已失效 使用static、全局变量或堆分配
删除后内存泄漏 空闲任务被阻塞,无法回收TCB和栈 确保空闲任务能获得CPU时间,不要无限阻塞它
中断中删除 在中断服务函数中调用vTaskDelete 使用xQueueSendFromISR通知任务,让任务自己删除
💡 我的调试小技巧

在开发阶段,我会给每个任务分配一个唯一ID,放在TCB的用户字段里(如果RTOS支持)。这样在调试器里看到栈回溯时,能立刻知道是哪个任务出了问题。另外,我习惯在任务函数的入口和出口加GPIO翻转,用逻辑分析仪看任务是否按预期调度——这比printf靠谱多了。

3.7 总结

任务创建与删除,看似简单,但细节决定成败。记住三点:

  • 创建时:栈深度单位是字,参数生命周期要管好
  • 运行时:句柄就是遥控器,丢了就失控了
  • 删除时:给空闲任务留机会,句柄记得清空

嗯,这一章就到这里。代码写多了,这些细节自然会变成肌肉记忆。下次我们聊聊任务的同步与通信——那才是真正让RTOS发挥威力的地方。