3. 任务控制块(TCB)设计与实现:结构体定义、链表管理、状态机

任务控制块,简称 TCB。说白了,它就是 RTOS 给每个任务办的「身份证」。你想想看,内核怎么知道哪个任务在跑?哪个任务在睡觉?哪个任务优先级高?全靠这张身份证。

我早年刚接触 RTOS 时,总觉得 TCB 不就是个结构体嘛,随便写写就行。后来在一个工业控制项目里,任务一多,系统莫名其妙就崩了。查了三天,最后发现是 TCB 链表指针被踩了。嗯,从那以后,我对 TCB 的设计再也不敢马虎了。

3.1 TCB 结构体定义——这张身份证上写什么?

一个合格的 TCB,至少得包含三部分信息:任务身份、任务资源、任务状态。我习惯把它们组织成下面这样:

typedef struct tcb {
    // 1. 任务身份
    uint32_t    task_id;            // 任务唯一ID
    char        task_name[16];      // 任务名字(调试用)

    // 2. 任务资源
    uint32_t    *stack_ptr;         // 当前栈指针 (SP)
    uint32_t    *stack_base;        // 栈底地址
    uint32_t    stack_size;         // 栈大小(字为单位)
    uint8_t     priority;           // 任务优先级 (0最高)

    // 3. 任务状态
    uint8_t     state;              // 当前状态 (就绪/阻塞/挂起/运行)
    uint32_t    delay_ticks;        // 延时剩余tick数

    // 4. 链表指针
    struct tcb  *next;              // 单向链表下一节点
    struct tcb  *prev;              // 双向链表上一节点

    // 5. 扩展字段(性能调优用)
    uint32_t    total_runtime;      // 累计运行时间 (tick)
    uint32_t    last_switch_time;   // 上次切换时间戳
} TCB_t;
我的习惯:task_name 字段别省。调试时能直接打印任务名,比看 ID 方便太多了。我曾经在一个 32 任务的项目里,全靠名字快速定位问题。

这里有个关键点——stack_ptr 必须指向任务被切换时的现场保存位置。ARMv7 的异常压栈顺序是固定的:xPSR、PC、LR、R12、R3-R0,然后是 R4-R11。所以 TCB 里的 stack_ptr,其实就是任务被切出时,SP 寄存器的值。

3.2 链表管理——任务怎么排队?

任务多了就得排队。RTOS 里最常用的就是双向链表。为什么不用数组?你想想看,任务创建和删除是动态的,数组的插入删除要搬移数据,效率太低。

我一般维护两个核心链表:

链表名称 作用 管理方式
就绪链表 存放所有可以运行的任务 按优先级排序,头插或尾插
阻塞链表 存放因延时或等待而暂停的任务 按 delay_ticks 排序,最小在前

链表操作的核心函数就三个:

// 插入节点(按优先级升序)
void tcb_list_insert(TCB_t **head, TCB_t *node) {
    TCB_t *curr = *head;
    TCB_t *prev = NULL;

    while (curr != NULL && curr->priority <= node->priority) {
        prev = curr;
        curr = curr->next;
    }

    node->next = curr;
    node->prev = prev;

    if (prev == NULL) {
        *head = node;           // 插在头部
    } else {
        prev->next = node;
    }
    if (curr != NULL) {
        curr->prev = node;
    }
}

// 删除节点
void tcb_list_remove(TCB_t **head, TCB_t *node) {
    if (node->prev == NULL) {
        *head = node->next;     // 删除头节点
    } else {
        node->prev->next = node->next;
    }
    if (node->next != NULL) {
        node->next->prev = node->prev;
    }
    node->next = NULL;
    node->prev = NULL;
}

// 查找最高优先级任务
TCB_t* tcb_list_get_highest(TCB_t *head) {
    return head;  // 链表已按优先级排序,头节点就是最高优先级
}
踩坑提醒:我曾经在链表删除时忘了更新头指针,结果调度器拿到的是一个野指针,系统直接 HardFault。记住:删除头节点时,一定要把 head 指向 head->next。

3.3 状态机——任务的一生

每个任务都有生命周期。RTOS 里任务状态一般有 4 种:

  • 就绪态 (READY):万事俱备,只差 CPU
  • 运行态 (RUNNING):正在占用 CPU
  • 阻塞态 (BLOCKED):等延时、等信号量、等消息
  • 挂起态 (SUSPENDED):被强制暂停,不参与调度

状态切换的规则其实很简单:

typedef enum {
    TASK_READY    = 0,
    TASK_RUNNING  = 1,
    TASK_BLOCKED  = 2,
    TASK_SUSPEND  = 3
} TaskState_t;

// 状态切换函数
void tcb_set_state(TCB_t *tcb, TaskState_t new_state) {
    TaskState_t old_state = tcb->state;

    // 从就绪/运行 -> 阻塞
    if ((old_state == TASK_READY || old_state == TASK_RUNNING) &&
        new_state == TASK_BLOCKED) {
        tcb_list_remove(&ready_list, tcb);
        tcb_list_insert(&blocked_list, tcb);
    }
    // 从阻塞 -> 就绪
    else if (old_state == TASK_BLOCKED && new_state == TASK_READY) {
        tcb_list_remove(&blocked_list, tcb);
        tcb_list_insert(&ready_list, tcb);
    }
    // 从就绪 -> 挂起
    else if (old_state == TASK_READY && new_state == TASK_SUSPEND) {
        tcb_list_remove(&ready_list, tcb);
        // 挂起任务单独维护
    }

    tcb->state = new_state;
}
核心原则:状态切换必须原子操作。ARMv7 里可以用 CPSID I 关中断来保护临界区,切换完再 CPSIE I 开中断。否则在链表操作中间被切走,链表就乱套了。

下面这张图,是我画给团队新人的任务状态流转图,你看一眼就明白了:

RTOS 任务状态机流转图 就绪态 运行态 阻塞态 挂起态 调度器选中 时间片用完/被抢占 调用延时/等待 延时到/资源就绪 任务挂起 API 任务恢复 API 说明:实线箭头表示主动切换,虚线箭头(未画出)表示被动切换。所有状态切换必须关中断保护。

3.4 初始化与销毁——TCB 的生死管理

任务创建时,TCB 怎么初始化?我一般这样做:

void tcb_init(TCB_t *tcb, char *name, uint8_t prio,
              uint32_t *stack, uint32_t stack_words) {
    // 清空结构体
    memset(tcb, 0, sizeof(TCB_t));

    // 填写身份信息
    strncpy(tcb->task_name, name, 15);
    tcb->task_name[15] = '\0';
    tcb->priority = prio;

    // 栈初始化
    tcb->stack_base = stack;
    tcb->stack_size = stack_words;

    // 初始栈指针指向栈顶
    // ARMv7 栈是向下生长的
    tcb->stack_ptr = stack + stack_words - 1;

    // 初始状态为就绪
    tcb->state = TASK_READY;
    tcb->delay_ticks = 0;

    // 插入就绪链表
    tcb_list_insert(&ready_list, tcb);
}
调试小技巧:初始化时把整个栈填成 0xDEADBEEF。任务运行一段时间后,检查栈空间有多少被覆盖了,就能算出实际栈使用量。这个在性能调优时特别有用。

任务销毁时,记得做三件事:从链表中摘除、释放资源、把 TCB 归还到空闲池。我习惯用一个空闲 TCB 链表来管理,创建时从空闲链表取,销毁时放回去,避免动态内存分配的开销。

3.5 性能调优视角——TCB 设计对调度的影响

TCB 设计得不好,调度性能直接打折。我总结了几条经验:

  • 结构体对齐:ARMv7 建议 8 字节对齐。把 uint32_t 和指针放前面,uint8_t 放后面,避免 padding 浪费。
  • 链表操作 O(1):就绪链表如果能做到 O(1) 取最高优先级任务,调度延迟就稳了。ARMv7 可以用位图法,32 级优先级刚好用一个 uint32_t 搞定。
  • 状态切换路径:最频繁的路径是「运行→就绪→运行」,这条路径上的代码要精简。我见过有人每次切换都遍历整个链表,那性能就太差了。
一句话总结:TCB 是 RTOS 的基石。结构体定义决定了资源占用,链表管理决定了调度效率,状态机决定了任务行为。这三样搞扎实了,你的 RTOS 就稳了一半。