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的指针。有了它,你可以在任务外部控制任务的挂起、恢复、删除、优先级调整等。
句柄的获取方式:
- 创建时获取:xTaskCreate的最后一个参数
- 运行时获取:xTaskGetHandle("task_name"),通过任务名查找
- 获取自身句柄:xTaskGetCurrentTaskHandle()
我个人的习惯是:创建任务时一定保存句柄,除非这个任务永远不需要被外部控制。你想想看,如果后续要加功能,没有句柄就得重构代码,多麻烦。
3.5 知识结构总览
下面这张图把任务创建与删除的核心流程串起来了:
3.6 避坑指南:我踩过的那些坑
| 场景 | 问题 | 解决方案 |
|---|---|---|
| 栈溢出 | 任务栈深度不够,导致栈溢出,系统随机崩溃 | 使用uxTaskGetStackHighWaterMark()监控栈余量,留出20%余量 |
| 句柄悬空 | 任务已删除,但其他代码仍用旧句柄操作 | 删除后立即置NULL,操作前检查句柄是否为NULL |
| 参数生命周期 | 传递局部变量地址,任务使用时变量已失效 | 使用static、全局变量或堆分配 |
| 删除后内存泄漏 | 空闲任务被阻塞,无法回收TCB和栈 | 确保空闲任务能获得CPU时间,不要无限阻塞它 |
| 中断中删除 | 在中断服务函数中调用vTaskDelete | 使用xQueueSendFromISR通知任务,让任务自己删除 |
在开发阶段,我会给每个任务分配一个唯一ID,放在TCB的用户字段里(如果RTOS支持)。这样在调试器里看到栈回溯时,能立刻知道是哪个任务出了问题。另外,我习惯在任务函数的入口和出口加GPIO翻转,用逻辑分析仪看任务是否按预期调度——这比printf靠谱多了。
3.7 总结
任务创建与删除,看似简单,但细节决定成败。记住三点:
- 创建时:栈深度单位是字,参数生命周期要管好
- 运行时:句柄就是遥控器,丢了就失控了
- 删除时:给空闲任务留机会,句柄记得清空
嗯,这一章就到这里。代码写多了,这些细节自然会变成肌肉记忆。下次我们聊聊任务的同步与通信——那才是真正让RTOS发挥威力的地方。