3. 任务与任务控制块:任务的概念、任务状态与TCB结构体

大家好,欢迎来到第三章。这一章我们聊聊任务本身。说白了,实时操作系统里最核心的东西就是任务。你写的所有代码,最终都要包装成一个一个的任务,交给内核去调度。

我记得刚入行那会儿,总把任务和函数搞混。觉得不就是个死循环嘛,有什么特别的?后来被坑了几次才明白——任务是有“生命”的,它有自己的状态、自己的栈、自己的“身份证”。这个身份证,就是任务控制块(TCB)。

3.1 任务到底是什么?

任务,在RTOS里也叫线程。它是一个独立的执行流,有自己的栈空间,有自己的优先级。你可以把它理解成一个“独立的小人”,这个小人一直在做某件事。

举个例子。一个PIS系统里,可能有这些任务:

  • 显示刷新任务:每隔50ms刷新一次屏幕
  • 按键扫描任务:检测用户有没有按按钮
  • 通信处理任务:处理串口或CAN总线来的数据
  • 心跳检测任务:每隔1秒让LED闪一下

这些任务看起来是“同时”运行的。但实际上,CPU只有一个。是操作系统在快速切换,让你感觉它们在并行。嗯,这里要注意——任务切换是有代价的,切换太频繁反而会降低效率。

我的经验:任务数量不是越多越好。我见过有人一个项目搞了30多个任务,结果光切换开销就占了30%的CPU。一般来说,5~10个任务就够用了。把功能相近的合并一下,反而更稳定。

3.2 任务的四种状态

任务不是一直在跑的。它会在不同状态之间跳来跳去。RTOS里,任务通常有四种状态:

状态 说明 通俗理解
就绪(Ready) 任务已经准备好,随时可以运行 排队等着叫号
运行(Running) 任务正在占用CPU执行 正在被服务
阻塞(Blocked) 任务在等待某个事件(如延时、信号量) 在座位上等餐
挂起(Suspended) 任务被强制暂停,不参与调度 被叫出去罚站

为什么会这样分?你想想看,如果所有任务都在抢CPU,那不乱套了?有了状态管理,操作系统就知道:谁该跑、谁在等、谁被罚站了。

我给大家画个状态转换图(用文字描述):

  • 任务创建后,进入就绪态
  • 调度器选中它,变成运行态
  • 运行中如果调用延时或等待资源,进入阻塞态
  • 阻塞条件满足后,回到就绪态
  • 运行中被更高优先级任务抢占,也回到就绪态
  • 调用挂起函数,进入挂起态;调用恢复函数,回到就绪态
重点记住:同一时刻,只有一个任务处于运行态。其他任务要么就绪,要么阻塞,要么挂起。这是单核CPU的铁律。

3.3 任务控制块(TCB)——任务的身份证

操作系统怎么管理这么多任务?靠的就是TCB。每个任务都有一个TCB,里面存着这个任务的所有信息。

我曾经在一个项目里调试一个奇怪的问题:任务跑着跑着就飞了。查了两天,最后发现是TCB里的栈指针被意外改写了。从那以后,我对TCB的结构就特别敏感。

一个典型的TCB结构体长这样:

typedef struct tcb {
    uint32_t    *stack_ptr;      // 当前栈指针(SP)
    uint32_t    *stack_base;     // 栈底地址
    uint32_t    stack_size;      // 栈大小(字节)
    
    uint8_t     priority;        // 任务优先级(0最高)
    uint8_t     state;           // 任务状态
    uint8_t     task_id;         // 任务ID
    
    void        (*entry)(void*); // 任务入口函数
    void        *param;          // 入口参数
    
    uint32_t    delay_ticks;     // 延时剩余ticks
    void        *wait_obj;       // 等待的对象(信号量等)
    
    struct tcb  *next;           // 链表指针
} TCB_t;

我来解释一下每个字段的用途:

  • stack_ptr / stack_base / stack_size:栈管理三件套。任务切换时,CPU的寄存器全部压栈,stack_ptr就指向栈顶。我建议栈大小至少给512字节,太小了容易溢出。
  • priority:优先级。数字越小优先级越高。FreeRTOS是数字越大优先级越高,这个看具体实现。
  • state:当前状态。用枚举值表示,比如0=就绪,1=运行,2=阻塞,3=挂起。
  • entry / param:任务入口函数和参数。创建任务时传进来的。
  • delay_ticks:任务调用延时函数时,这里存剩余要等的tick数。每次tick中断减1,减到0就唤醒。
  • wait_obj:如果任务在等信号量或消息队列,这里指向那个对象。
  • next:链表指针。所有就绪任务串成一个链表,方便调度器遍历。
避坑指南:我曾经犯过一个错——在中断里直接修改了某个任务的state字段。结果调度器一运行,发现状态和实际不符,直接死机。记住:TCB的修改必须通过系统API,不要自己动手。

3.4 任务创建与初始化

有了TCB结构体,我们怎么创建一个任务?大致流程是这样的:

  1. 分配一块内存给TCB(可以从静态数组或动态堆里取)
  2. 分配一块内存给任务栈
  3. 初始化TCB的各个字段
  4. 把任务加入就绪链表

代码示例:

TCB_t task_tcb;
uint32_t task_stack[128];  // 512字节栈

void task_create(void (*entry)(void*), void *param, uint8_t prio) {
    // 1. 初始化栈(模拟一次任务切换时的压栈)
    uint32_t *sp = &task_stack[127];
    *(--sp) = (uint32_t)entry;   // PC
    *(--sp) = 0x01000000;        // xPSR
    // ... 其他寄存器
    
    // 2. 填充TCB
    task_tcb.stack_ptr  = sp;
    task_tcb.stack_base = task_stack;
    task_tcb.stack_size = sizeof(task_stack);
    task_tcb.priority   = prio;
    task_tcb.state      = READY;
    task_tcb.entry      = entry;
    task_tcb.param      = param;
    task_tcb.delay_ticks= 0;
    task_tcb.wait_obj   = NULL;
    
    // 3. 加入就绪链表
    ready_list_add(&task_tcb);
}

这里有个细节:初始化栈的时候,我们模拟了一次“假切换”。把入口地址当成返回地址压进去。这样调度器第一次切换到这个任务时,就会从入口函数开始执行。嗯,这个技巧很多RTOS都在用。

3.5 任务状态切换的实际场景

光讲理论没意思。我给大家说个实际场景:

一个PIS系统里,有个“显示任务”优先级是3,有个“按键任务”优先级是5(数字越小优先级越高)。

  • 一开始,显示任务在运行,按键任务就绪。
  • 显示任务调用了 delay(100),进入阻塞态。
  • 调度器一看,按键任务就绪,切过去运行。
  • 100ms后,tick中断来了,显示任务的delay_ticks减到0,状态变回就绪。
  • 但按键任务还在跑,因为调度器不会主动打断它(除非是抢占式调度)。
  • 按键任务也调用了 delay(50),进入阻塞。
  • 调度器发现显示任务优先级更高,切回显示任务。

你看,就这么几个来回,任务状态就在就绪、运行、阻塞之间跳来跳去。挂起态用得少一些,一般是在调试或者低功耗模式下才会用到。

我的习惯:调试时我喜欢把每个任务的TCB信息打印出来。特别是stack_ptr和stack_base的差值,能看出栈用了多少。如果发现某个任务栈占用超过70%,我就会给它加栈。别等到栈溢出了再查,那会儿已经晚了。

3.6 本章小结

这一章我们聊了三个核心概念:

  • 任务:独立的执行流,有自己的栈和优先级
  • 任务状态:就绪、运行、阻塞、挂起,四种状态来回切换
  • 任务控制块(TCB):任务的身份证,存了栈指针、优先级、状态等关键信息

下一章我们会深入调度器的实现。到时候你就知道,操作系统是怎么从就绪链表里挑出下一个任务来跑的。嗯,那才是真正精彩的部分。

记住一句话:理解了TCB,你就理解了RTOS的一半。剩下的,都是围绕TCB转的算法和机制。