4、任务创建与管理:任务控制块(TCB)、任务状态机、优先级与堆栈分配

各位做嵌入式开发的同行,今天我们来聊聊RTOS里最核心的东西——任务管理。说实话,我见过不少工程师,代码写得挺溜,但一问到任务控制块里到底存了啥,就含糊了。这不行,底层逻辑不清楚,出bug的时候你根本无从下手。

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

任务控制块,简称TCB。你可以把它想象成每个任务在系统里的“身份证”或“档案袋”。系统调度器不关心你的任务函数名叫什么,它只认TCB。

一个典型的TCB结构体长什么样?我直接贴一段我项目里用过的简化版:

typedef struct tcb {
    uint32_t    *stack_ptr;      // 当前栈顶指针
    uint32_t    *stack_base;     // 栈底地址
    uint32_t     stack_size;     // 栈大小(字)
    uint8_t      priority;       // 任务优先级
    uint8_t      state;          // 任务状态
    char         name[16];       // 任务名称(调试用)
    void        *msg_queue;      // 消息队列指针
    struct tcb  *next;           // 链表指针
} TCB_t;

这里面每个字段都有讲究。比如stack_ptr,它指向的是当前CPU寄存器的保存位置。任务切换时,CPU把现场压栈,然后stack_ptr就指向这个栈帧。我刚开始做移植时,就因为这个指针没对齐,导致任务切换后PC指针飞了,查了两天才找到原因。

TCB里最关键的三个信息:

  • 栈信息:栈顶、栈底、栈大小。这是任务运行的“物理空间”。
  • 优先级:决定了谁先跑。后面细说。
  • 状态:当前任务在哪个状态机里。
我的习惯: 每个TCB我都会预留一个“任务名”字段,哪怕只占8个字节。调试时用printf打印出所有任务的名字和状态,比看地址方便一万倍。

4.2 任务状态机——就绪、运行、阻塞、挂起

任务在系统里不是一直跑着的。它会在几个状态之间跳来跳去。我画个简单的状态转换图给你看:

        创建
          |
          v
       [就绪] ----调度器选中----> [运行]
          ^                        |
          |                        | 时间片到/被抢占
          |                        v
          +--------------------- [就绪]
          |
          | 等待事件/延时
          v
       [阻塞] ----事件到达----> [就绪]
          |
          | 调用挂起API
          v
       [挂起] ----恢复API----> [就绪]

这里我重点说几个容易混淆的地方:

  • 就绪 vs 运行:就绪是说“我可以跑了,但CPU还没轮到我”。运行是“CPU正在执行我的代码”。说白了,就绪态的任务在排队,运行态的任务在干活。
  • 阻塞 vs 挂起:这两个经常有人搞混。阻塞是任务主动等某个事件(比如等信号量、等延时),事件到了自动回就绪。挂起是被别的任务或中断强制暂停了,必须由别人来恢复。我遇到过有人用挂起当阻塞用,结果任务永远醒不过来。
我曾经踩过的坑: 在某个电机控制项目里,我把一个高优先级任务设成了阻塞态等消息,但消息发送任务优先级更低。结果高优先级任务一直占着CPU不放,低优先级任务根本没机会发消息。这就是典型的“优先级反转”前兆。后来我加了优先级继承才解决。

4.3 任务优先级与堆栈分配

优先级这东西,说白了就是“谁更重要谁先跑”。但怎么定优先级,这里头学问大了。

优先级分配原则(我个人的经验):

  1. 硬实时任务最高:比如电机电流环、PWM输出,这些必须按时完成,优先级给最高(比如0或1)。
  2. 软实时任务次之:比如通信协议栈、数据采集,可以偶尔延迟但不能丢。
  3. 后台任务最低:比如状态显示、日志打印,跑慢点无所谓。

堆栈分配更是个技术活。给多了浪费RAM,给少了栈溢出。我一般这样估算:

任务类型 典型栈大小(32位MCU) 说明
简单控制任务(如按键扫描) 128 ~ 256 字节 函数调用深度浅,局部变量少
中等任务(如通信协议) 512 ~ 1024 字节 可能有递归或大数组
复杂任务(如文件系统) 2048 ~ 4096 字节 函数调用链长,缓冲区大
避坑指南: 我曾经在一个产品里,给每个任务只分配了256字节栈。结果某个任务里有个临时数组char buf[200],加上函数调用,栈直接爆了。现象很诡异——任务跑着跑着就死机,而且不是每次都复现。后来用栈水印检测才发现,栈使用率已经到98%了。从那以后,我每个任务都留30%的余量。

4.4 动态创建与静态创建的区别

这个问题面试常问,实际项目里也经常要选。我直接给你对比:

对比项 动态创建(如xTaskCreate) 静态创建(如xTaskCreateStatic)
TCB和栈内存来源 从堆(Heap)分配 由用户提供静态数组
创建时机 运行时随时创建 编译时确定,初始化时创建
内存碎片风险 有(频繁创建删除时)
确定性 低(可能分配失败) 高(编译时就确定了)
适用场景 任务数量不固定、需要热插拔 任务固定、安全关键系统

我个人在工业控制项目里,90%的情况都用静态创建。为什么?因为工业现场不允许“内存分配失败”这种不确定性。你想想看,一个PLC正在控制流水线,突然某个任务因为堆碎片创建失败了——这后果谁担得起?

动态创建也有它的用武之地。比如一个调试任务,只在需要时才创建,调试完就删掉。或者一个用户配置界面,根据配置动态生成任务。但记住,动态创建一定要检查返回值!

// 动态创建示例
TaskHandle_t task_handle = NULL;
if (xTaskCreate(vTaskFunction, "MyTask", 256, NULL, 5, &task_handle) != pdPASS) {
    // 创建失败!必须处理
    error_handler();
}

// 静态创建示例
static StackType_t task_stack[256];
static StaticTask_t task_tcb;
TaskHandle_t task_handle = xTaskCreateStatic(
    vTaskFunction, "MyTask", 256, NULL, 5,
    task_stack, &task_tcb
);
// 静态创建不会失败,但前提是你给的栈够大

4.5 实际项目中的任务划分经验

这部分是我最想跟你分享的。很多新手拿到一个项目,不知道该怎么切任务。我总结了几条原则:

原则一:按功能独立性划分

每个任务应该完成一个相对独立的功能。比如“读取传感器数据”是一个任务,“处理控制算法”是另一个任务,“发送通信报文”又是一个任务。这样每个任务逻辑清晰,也方便调试。

原则二:按时间紧迫性划分

把时间要求严格的功能单独拎出来。比如一个100us的电流环,必须独占一个高优先级任务。而一个100ms的温湿度采集,可以跟其他慢速任务合并。

原则三:避免任务间耦合太深

我见过有人把A任务的输出直接作为B任务的输入,两个任务通过全局变量传数据。结果A改了数据结构,B忘了改,编译不报错,运行就崩。正确的做法是用队列或信号量传递数据,接口清晰。

原则四:任务数量不是越多越好

每个任务都有TCB和栈开销。一个Cortex-M3上跑20个任务,光栈就可能吃掉几十KB RAM。而且任务切换也有开销。我一般建议:8位MCU不超过4个任务,32位MCU不超过16个任务。多了反而降低实时性。

一个实际案例: 我之前做的一个工业网关项目,最初分了12个任务:WiFi扫描、TCP连接、MQTT发布、串口接收、串口解析、数据缓存、状态显示、按键扫描、OTA升级、日志记录、看门狗喂狗、系统监控。后来发现很多任务其实可以合并。比如“串口接收”和“串口解析”完全可以合并成一个任务,用状态机处理。最终精简到7个任务,RAM占用少了30%,系统响应反而更快了。

嗯,任务划分没有标准答案,更多是经验积累。你做得多了,自然就知道哪些功能该放一起,哪些该分开。记住一句话:任务之间通信越少,系统越稳定