2. 任务与任务控制块:任务的定义、任务状态与TCB结构体设计

大家好,欢迎来到第二章。这一章我们聊聊RTOS里最核心的两个概念——任务和任务控制块。说白了,任务就是你要让CPU干的那件具体的事,而任务控制块就是记录这件事所有信息的“档案袋”。

我在做智能手表项目时,一开始觉得任务控制块不就是个结构体嘛,随便写写就行。结果有一次手表死机,查了三天才发现是TCB里一个状态位没处理好。嗯,从那以后我再也不敢小看这个“档案袋”了。

2.1 任务的定义

任务是什么?在RTOS里,任务就是一个无限循环的函数。它有自己的栈空间,有自己的优先级,还有自己的状态。你想想看,一个手表上要同时处理显示、触摸、蓝牙通信、心率监测……这些功能如果都写在一个大循环里,那代码维护起来简直是噩梦。

所以我们要把每个功能拆成独立的任务。比如:

// 一个典型的心率监测任务
void heart_rate_task(void *arg) {
    while(1) {
        read_heart_rate_sensor();
        process_heart_rate_data();
        update_display();
        vTaskDelay(100);  // 每100ms执行一次
    }
}

注意看,这个任务是个死循环。为什么?因为任务一旦返回,RTOS就会认为它结束了,会把它从调度队列里移除。所以任务函数通常写成 while(1) 的形式。

核心要点:每个任务必须是一个永不返回的无限循环函数。如果任务执行完了,记得调用任务删除函数,否则系统会出问题。

2.2 任务状态:就绪、运行、阻塞、挂起

任务不是一直在跑的。它会在不同状态之间切换。我习惯把任务状态比作人的一天:

  • 运行态:正在干活,CPU归你管
  • 就绪态:活都准备好了,就等CPU分配时间
  • 阻塞态:在等某个条件,比如等数据、等延时
  • 挂起态:被强制暂停,不参与调度

为什么会这样?因为CPU只有一个(单核),但任务可能有十几个。CPU只能同时运行一个任务,其他任务要么等着,要么在睡觉。

我在项目中遇到过一个问题:手表屏幕刷新任务和触摸检测任务优先级一样,结果触摸总是卡顿。后来发现是刷新任务占用了太多CPU时间,触摸任务一直处于就绪态却得不到运行。解决办法是把触摸任务的优先级调高一级。

避坑指南:我曾经把任务优先级设得一模一样,结果两个任务轮流运行,但切换太频繁导致功耗飙升。后来我学会了:实时性要求高的任务给高优先级,其他任务给低优先级,别搞平均主义。

状态切换的典型场景:

当前状态 触发事件 下一状态
就绪 调度器选中该任务 运行
运行 时间片用完 就绪
运行 等待信号量/队列 阻塞
阻塞 等待的条件满足 就绪
运行/就绪 调用挂起函数 挂起
挂起 调用恢复函数 就绪

注意:挂起态和阻塞态不一样。阻塞态是任务主动去等的,比如等延时、等消息。挂起态是被别人强制暂停的,比如调试时暂停某个任务。两者在调度器里的处理方式完全不同。

2.3 任务控制块(TCB)结构体设计

TCB是RTOS里最重要的数据结构。每个任务都有一个TCB,调度器就是靠TCB来管理所有任务的。我设计TCB时,会把它分成几个部分:

typedef struct tcb {
    // 1. 栈信息
    uint32_t *stack_top;      // 栈顶指针
    uint32_t *stack_base;     // 栈底指针
    uint32_t  stack_size;     // 栈大小(字节)
    
    // 2. 任务状态
    uint8_t   state;          // 任务状态:就绪/运行/阻塞/挂起
    uint8_t   priority;       // 任务优先级(0最高)
    
    // 3. 任务标识
    char      name[16];       // 任务名字,方便调试
    uint32_t  task_id;        // 任务ID,唯一标识
    
    // 4. 调度相关
    uint32_t  delay_ticks;    // 阻塞延时计数
    void     *wait_obj;       // 等待的对象(信号量/队列等)
    
    // 5. 链表节点
    struct tcb *next;         // 用于就绪队列/阻塞队列的链表指针
    
    // 6. 扩展信息
    uint32_t  run_time;       // 累计运行时间(用于统计)
    uint32_t  stack_watermark;// 栈使用峰值(用于调试)
    
} tcb_t;

这个结构体看起来字段不少,但每个都有它的用处。我重点说说几个容易忽略的地方:

栈信息:每个任务都有自己的栈。为什么?因为任务切换时要保存上下文(寄存器、局部变量等)。如果所有任务共用一个栈,那切换时数据就乱套了。栈大小怎么定?我一般先给个512字节,跑起来后用 stack_watermark 字段监控实际使用量,再调整。

wait_obj:这个字段很关键。当任务阻塞时,它记录任务在等什么。比如等一个信号量,那 wait_obj 就指向那个信号量。这样当信号量释放时,系统能快速找到哪些任务在等它,直接唤醒。

链表节点:TCB不是孤立的。就绪队列、阻塞队列、挂起队列……每个队列都是一个链表。TCB里的 next 指针就是用来串起这些链表的。我见过有人用数组管理任务,但链表更灵活,增删任务不用搬动内存。

设计原则:TCB结构体要尽量精简。嵌入式系统的RAM很宝贵,每个任务多占4个字节,100个任务就多占400字节。所以只放必要字段,不要什么都往里塞。

举个例子,我早期设计的TCB里有个 error_code 字段,想着记录任务出错信息。后来发现根本没用上,反而浪费了4个字节。现在我的做法是:调试阶段用宏定义开启扩展字段,正式发布时关掉。

2.4 任务创建与初始化

有了TCB结构体,我们来看看怎么创建一个任务:

tcb_t *task_create(void (*task_func)(void *), 
                   void *arg,
                   uint8_t priority,
                   uint32_t stack_size,
                   const char *name) {
    
    // 1. 分配TCB内存
    tcb_t *new_task = (tcb_t *)malloc(sizeof(tcb_t));
    if(!new_task) return NULL;
    
    // 2. 分配栈内存
    uint32_t *stack = (uint32_t *)malloc(stack_size);
    if(!stack) {
        free(new_task);
        return NULL;
    }
    
    // 3. 初始化TCB字段
    new_task->stack_base = stack;
    new_task->stack_size = stack_size;
    new_task->priority = priority;
    new_task->state = TASK_READY;
    new_task->task_id = get_next_task_id();
    strncpy(new_task->name, name, 15);
    new_task->delay_ticks = 0;
    new_task->wait_obj = NULL;
    new_task->run_time = 0;
    new_task->stack_watermark = 0;
    
    // 4. 初始化栈(设置初始上下文)
    new_task->stack_top = stack_init(stack, stack_size, task_func, arg);
    
    // 5. 加入就绪队列
    ready_queue_add(new_task);
    
    return new_task;
}

这里有个细节:stack_init 函数。它负责在栈里预填好任务第一次运行时的寄存器值。比如CPU的PC指针要指向任务函数入口,SP指针要指向栈顶。这样第一次切换到这个任务时,CPU直接从栈里恢复上下文,就能开始运行了。

我的习惯:创建任务时,我会给每个任务起个有意义的名字,比如"heart_rate"、"display"、"touch"。这样在调试时,通过名字就能知道哪个任务在跑,哪个任务卡住了。别偷懒用"task1"、"task2",调试时你会后悔的。

2.5 任务状态管理

任务状态不是随便改的。我设计了一套状态转换函数,保证状态切换的原子性:

// 任务进入阻塞态
void task_block(tcb_t *task, uint32_t ticks, void *wait_obj) {
    task_enter_critical();  // 关中断,防止竞争
    task->state = TASK_BLOCKED;
    task->delay_ticks = ticks;
    task->wait_obj = wait_obj;
    ready_queue_remove(task);
    blocked_queue_add(task);
    task_exit_critical();   // 开中断
}

// 任务进入就绪态
void task_ready(tcb_t *task) {
    task_enter_critical();
    task->state = TASK_READY;
    task->wait_obj = NULL;
    blocked_queue_remove(task);
    ready_queue_add(task);
    task_exit_critical();
}

// 任务挂起
void task_suspend(tcb_t *task) {
    task_enter_critical();
    if(task->state == TASK_READY) {
        ready_queue_remove(task);
    } else if(task->state == TASK_BLOCKED) {
        blocked_queue_remove(task);
    }
    task->state = TASK_SUSPENDED;
    suspended_queue_add(task);
    task_exit_critical();
}

注意看,每个状态切换函数都有关中断保护。为什么?因为任务切换可能发生在中断里。如果不保护,中断里修改了状态,主循环也在修改,数据就乱了。

曾经踩过的坑:我一开始没加临界区保护,结果在中断里唤醒一个任务时,主循环刚好也在操作同一个TCB。两个地方同时写 state 字段,导致任务状态变成了一个非法值。从那以后,所有TCB操作我都加了临界区保护。

2.6 小结

这一章我们讲了任务的定义、四种状态以及TCB结构体的设计。说白了,任务就是一段代码,TCB就是这段代码的身份证。调度器通过TCB知道每个任务在干什么、优先级多高、栈用了多少。

下一章我们会讲任务调度算法,到时候你会看到TCB里的优先级字段和链表指针是怎么被调度器玩出花样的。嗯,敬请期待。