4. 任务创建与删除API实现:动态创建、静态创建、任务栈分配

任务调度是RTOS的心脏,那任务本身怎么生、怎么灭?说白了,就是任务创建与删除。这部分我当年刚接触RTOS时也踩过不少坑,尤其是栈分配那点事,搞不好就系统崩溃。今天咱们就把动态创建、静态创建和任务栈分配这几个核心点彻底聊透。

4.1 任务控制块(TCB)——任务的“身份证”

每个任务在RTOS内核里都有一个对应的数据结构,叫任务控制块(Task Control Block,TCB)。你可以把它理解成任务的“身份证”,内核靠它来管理任务的一切。

一个典型的TCB长什么样?我直接给个精简版定义:

typedef struct tcb {
    uint32_t    *sp;          // 栈指针,指向当前栈顶
    uint8_t     priority;     // 任务优先级
    uint32_t    state;        // 任务状态:就绪、阻塞、挂起等
    uint32_t    stack_size;   // 栈大小(字节)
    uint32_t    *stack_base;  // 栈底地址
    void        (*entry)(void *arg); // 任务入口函数
    void        *arg;         // 入口函数参数
    const char  *name;        // 任务名,调试用
    struct tcb  *next;        // 链表指针
} tcb_t;

嗯,这里要注意:sp 和 stack_base 是两个不同的指针。stack_base 指向栈的起始地址,sp 指向当前栈顶。ARMv7的栈是向下生长的,所以初始化时 sp = stack_base + stack_size。

个人习惯:我一般在TCB里加一个 magic_number 字段,初始化为固定值(比如0xDEADBEEF)。任务删除时检查这个值,能快速发现野指针或重复删除的问题。

4.2 动态创建 vs 静态创建

RTOS里创建任务有两种方式:动态创建和静态创建。它们的核心区别就一句话——谁负责分配TCB和栈内存。

对比项 动态创建 静态创建
内存来源 内核堆(heap) 用户提供的静态缓冲区
API示例 task_create() task_create_static()
灵活性 高,运行时按需创建 低,编译时确定
确定性 低,可能分配失败 高,无运行时分配开销
适用场景 原型开发、任务数量动态变化 安全关键系统、资源受限设备

我个人更倾向于在项目初期用动态创建,快速验证功能。但到了产品阶段,尤其是汽车电子、医疗设备这类场景,我会全部换成静态创建——你想想看,运行时malloc失败导致任务创建不了,这锅谁背得起?

4.2.1 动态创建实现

动态创建的核心流程:从堆中分配TCB和栈内存,然后初始化TCB字段,最后把任务加入就绪队列。

int task_create(tcb_t **task_handle,
                const char *name,
                void (*entry)(void *arg),
                void *arg,
                uint8_t priority,
                uint32_t stack_size)
{
    tcb_t    *tcb;
    uint32_t *stack;

    // 1. 分配TCB
    tcb = (tcb_t *)kmalloc(sizeof(tcb_t));
    if (!tcb) return -1;

    // 2. 分配栈空间
    stack = (uint32_t *)kmalloc(stack_size);
    if (!stack) {
        kfree(tcb);
        return -1;
    }

    // 3. 初始化TCB
    tcb->name       = name;
    tcb->entry      = entry;
    tcb->arg        = arg;
    tcb->priority   = priority;
    tcb->stack_base = stack;
    tcb->stack_size = stack_size;
    tcb->state      = TASK_READY;

    // 4. 初始化栈帧(ARMv7上下文)
    tcb->sp = task_stack_init(stack, stack_size, entry, arg);

    // 5. 加入就绪队列
    task_ready_enqueue(tcb);

    *task_handle = tcb;
    return 0;
}
我曾经踩过的坑:动态创建时,TCB分配成功但栈分配失败,直接返回-1却忘了释放TCB。结果就是内存泄漏。正确的做法是:要么先分配栈再分配TCB,要么失败时做好回滚。

4.2.2 静态创建实现

静态创建需要用户提前准备好TCB和栈的缓冲区。内核只做初始化,不负责分配。

typedef struct {
    tcb_t    tcb;
    uint32_t stack[512];  // 2KB栈,用户自己定大小
} task_control_t;

int task_create_static(task_control_t *task_ctrl,
                       const char *name,
                       void (*entry)(void *arg),
                       void *arg,
                       uint8_t priority)
{
    tcb_t *tcb = &task_ctrl->tcb;

    tcb->name       = name;
    tcb->entry      = entry;
    tcb->arg        = arg;
    tcb->priority   = priority;
    tcb->stack_base = task_ctrl->stack;
    tcb->stack_size = sizeof(task_ctrl->stack);
    tcb->state      = TASK_READY;

    tcb->sp = task_stack_init(task_ctrl->stack,
                               sizeof(task_ctrl->stack),
                               entry, arg);

    task_ready_enqueue(tcb);
    return 0;
}

静态创建的好处是:没有malloc,没有内存碎片,执行时间完全确定。我曾在一些对实时性要求极高的项目中,所有任务都用静态创建,调度延迟能控制在个位数微秒级别。

4.3 任务栈分配——最容易翻车的地方

栈分配是任务创建里最容易被低估的环节。栈给大了浪费内存,给小了直接栈溢出,系统死给你看。

4.3.1 栈里到底放了什么?

一个任务的栈空间主要存放以下几类数据:

  • 函数调用栈帧:局部变量、返回地址、寄存器保存
  • 中断/异常上下文:ARMv7进入异常时自动压栈的寄存器(xPSR, PC, LR, R0-R3, R12)
  • 嵌套中断:如果允许中断嵌套,每个嵌套层都要额外空间
  • 任务切换上下文:PendSV中保存的R4-R11

你想想看,如果一个任务里调用了printf,那栈消耗可能瞬间飙升几百字节。我见过有人给一个打印任务只分配了256字节栈,结果一跑就崩。

4.3.2 栈大小估算方法

我一般用两种方法结合来估算栈大小:

  1. 静态分析法:手动计算任务中所有函数调用的最大嵌套深度,加上中断上下文开销。适合简单任务。
  2. 运行时监测法:在TCB里记录栈使用的水位线(high watermark),跑一段时间后查看实际峰值。

水位线监测的实现很简单:

void task_stack_monitor(tcb_t *tcb) {
    uint32_t *stack = tcb->stack_base;
    uint32_t  used  = 0;

    // 从栈底开始扫描,找第一个非0xDEADBEEF的字节
    for (uint32_t i = 0; i < tcb->stack_size / 4; i++) {
        if (stack[i] != 0xDEADBEEF) {
            used = (tcb->stack_size / 4 - i) * 4;
            break;
        }
    }

    if (used > tcb->stack_peak) {
        tcb->stack_peak = used;
    }
}
关键点:初始化栈时,把整个栈空间填充为0xDEADBEEF。任务运行后,从栈底往上扫描,第一个不是0xDEADBEEF的位置就是实际使用边界。这样就能精确知道峰值栈使用量。

4.4 任务删除API实现

任务删除比创建更危险。为什么?因为你要确保任务不在运行中、不持有资源、不处于临界区。

int task_delete(tcb_t *tcb) {
    // 1. 安全检查:不能删除当前正在运行的任务
    if (tcb == current_task) {
        return -1;  // 需要先切换到其他任务
    }

    // 2. 从所有队列中移除
    task_ready_remove(tcb);
    task_wait_remove(tcb);  // 如果任务在等待队列中

    // 3. 释放资源(如果有注册资源清理回调)
    if (tcb->cleanup) {
        tcb->cleanup(tcb->arg);
    }

    // 4. 释放内存(动态创建时)
    if (tcb->flags & TASK_FLAG_DYNAMIC) {
        kfree(tcb->stack_base);
        kfree(tcb);
    }

    return 0;
}
我曾经犯过的错:在中断服务函数里直接调用task_delete删除一个正在等待信号量的任务。结果那个任务还在等待队列里,删除后等待队列的链表指针变成了野指针,系统随机崩溃。正确的做法是:在删除前先检查任务状态,如果任务在等待队列,必须先将其移出。

4.5 知识结构总览

下面这张图把任务创建与删除的核心脉络梳理清楚了:

任务创建与删除知识结构图 任务创建与删除核心脉络 任务生命周期管理 任务创建 任务删除 栈分配与监测 动态创建 静态创建 安全检查 资源清理 栈大小估算 水位线监测 kmalloc分配 TCB + 栈 用户提供缓冲区 无运行时分配 不能删当前任务 需移出所有队列 静态分析+实测 留20%余量 0xDEADBEEF填充 扫描找峰值 核心原则:创建要稳健,删除要安全,栈要留余量 动态灵活但小心内存泄漏,静态确定但提前规划

4.6 避坑指南与最佳实践

最后,我把自己这些年积累的几个关键经验分享给你:

  • 栈大小留余量:估算值再乘以1.2~1.5。别抠那几十字节,栈溢出查起来比多分配内存痛苦一万倍。
  • 动态创建一定要有错误处理:kmalloc失败怎么办?TCB分配成功但栈分配失败怎么办?每个分支都要考虑。
  • 删除任务前先确保它不在运行:我习惯用一个引用计数,任务每被调度一次就加1,删除时检查引用是否为0。
  • 调试阶段开启栈溢出检测:ARMv7有MPU(内存保护单元),可以给栈区域设置不可写保护页,溢出时直接触发异常,定位问题快得多。
  • 静态创建的任务名用宏定义:比如 #define TASK_NAME_LED "led_ctrl",方便调试时快速识别。
我的调试小技巧:在TCB里加一个 uint32_t last_run_tick 字段,每次任务切换时更新。如果发现某个任务长时间没被调度,大概率是优先级设置有问题或者任务把自己挂起了没恢复。

任务创建与删除看似基础,但它是整个RTOS稳定性的基石。把这块做扎实了,后面的调度、同步、通信才能跑得稳。嗯,这部分内容就到这,代码该敲的敲,栈该测的测,别偷懒。