2. 嵌入式实时操作系统基础

任务管理,说白了就是 RTOS 的心脏。你想想看,一个嵌入式系统里跑着十几个任务,谁先执行、谁后执行、谁被抢了 CPU 还得乖乖等着——这些全靠任务管理来协调。ThreadX 的任务管理设计得相当精巧,咱们今天就把它的底裤扒干净。

任务控制块(TX_THREAD)——每个任务的身份证

每个任务在 ThreadX 里都有一个对应的控制块,类型就是 TX_THREAD。我刚开始用 ThreadX 时,以为它就是个简单的结构体,后来踩了坑才发现——里面藏着不少门道。

核心认知:TX_THREAD 不是普通的变量,它是任务在系统里的"户口本"。系统调度器靠它来定位任务、切换上下文、管理状态。

咱们直接看定义(简化版,保留核心字段):

typedef struct TX_THREAD_STRUCT {
    // 任务链表指针
    struct TX_THREAD_STRUCT *tx_thread_next;
    struct TX_THREAD_STRUCT *tx_thread_previous;

    // 任务入口和参数
    VOID (*tx_thread_entry)(ULONG);
    ULONG  tx_thread_entry_input;

    // 栈指针和栈空间
    VOID   *tx_thread_stack_ptr;
    VOID   *tx_thread_stack_start;
    ULONG   tx_thread_stack_size;

    // 优先级和时间片
    UINT    tx_thread_priority;
    UINT    tx_thread_time_slice;

    // 任务状态
    UINT    tx_thread_state;

    // 任务名称(调试用)
    CHAR   *tx_thread_name;

    // 抢占阈值(低功耗相关)
    UINT    tx_thread_preempt_threshold;

} TX_THREAD;

这里我重点说几个容易忽略的字段:

  • tx_thread_stack_ptr:当前栈指针,上下文切换时直接操作它。我曾经在移植时忘了初始化这个字段,结果任务第一次切换就崩了。
  • tx_thread_time_slice:时间片轮转的配额,单位是 tick。设为 0 表示不参与轮转。
  • tx_thread_preempt_threshold:抢占阈值,这是 ThreadX 的特色。低功耗场景下经常用它来减少不必要的任务切换。

我的习惯:每个任务创建后,我都会在调试阶段打印出它的控制块地址和栈范围。这样一旦发生栈溢出,能快速定位是哪个任务出了问题。

任务创建与删除 API——动手写代码

ThreadX 创建任务的 API 是 tx_thread_create,删除是 tx_thread_delete。别看就两个函数,参数细节能坑死人。

tx_thread_create 参数详解

参数 含义 注意点
thread_ptr 指向 TX_THREAD 控制块的指针 必须指向已分配的内存,不能是 NULL
name_ptr 任务名称字符串 建议用有意义的名称,调试时一目了然
entry_function 任务入口函数 函数原型必须是 VOID func(ULONG)
entry_input 入口参数 可以传一个 ULONG 值,或者传指针
stack_start 栈起始地址 必须对齐到 4 字节(ARM 下是 8 字节)
stack_size 栈大小(字节) 建议至少 512 字节,复杂任务 1024+
priority 优先级(0-31) 0 最高,31 最低
preempt_threshold 抢占阈值 通常等于 priority,低功耗时可调高
time_slice 时间片(tick 数) 0 表示不轮转,同优先级任务才生效
auto_start 是否自动启动 TX_AUTO_START 或 TX_DONT_START

来个实际例子:

TX_THREAD my_task;
UCHAR my_task_stack[1024];

void my_task_entry(ULONG param) {
    while(1) {
        // 任务逻辑
        tx_thread_sleep(10);
    }
}

void app_main(void) {
    UINT status;

    status = tx_thread_create(
        &my_task,               // 控制块
        "My Task",              // 名称
        my_task_entry,          // 入口
        0,                      // 参数
        my_task_stack,          // 栈起始
        sizeof(my_task_stack),  // 栈大小
        5,                      // 优先级
        5,                      // 抢占阈值
        10,                     // 时间片 10 ticks
        TX_AUTO_START           // 自动启动
    );

    if (status != TX_SUCCESS) {
        // 处理错误
    }
}

我曾经踩过的坑:栈大小给得太小,任务里调用了 printf 这种吃栈的函数,结果跑着跑着就 HardFault。排查了两天才发现是栈溢出。建议:任务栈至少给 512 字节,如果用了浮点运算或大量局部变量,1024 起步。

tx_thread_delete 注意事项

删除任务比创建更讲究。我见过不少新手直接调用 tx_thread_delete 然后系统就挂了。原因很简单——任务不能删除自己。

  • 只能从其他任务删除目标任务:比如在管理任务里删除一个已完成的工作任务。
  • 目标任务必须处于终止状态:先调用 tx_thread_terminate,再删除。
  • 删除后控制块和栈不会自动释放:如果你动态分配了这些内存,记得自己 free。
// 正确的删除流程
tx_thread_terminate(&my_task);   // 先终止
tx_thread_delete(&my_task);      // 再删除
// 如果 my_task 和栈是动态分配的,还要 free

任务优先级与时间片轮转——调度器的两把刷子

ThreadX 的调度策略其实很简单:优先级抢占 + 同优先级时间片轮转。但简单不代表容易用好。

优先级:0 到 31 的江湖

优先级 0 最高,31 最低。数字越小,地位越高。高优先级任务就绪时,低优先级任务立马被踢出去——这就是抢占。

关键规则:只要高优先级任务就绪,低优先级任务就别想跑。哪怕高优先级任务只跑一个 tick,也得让它先跑。

我个人的优先级分配原则:

  • 0-3:中断处理、时间关键型任务(比如电机控制)
  • 4-10:实时通信任务(CAN、SPI 从机)
  • 11-20:普通周期任务(传感器采集、数据显示)
  • 21-31:后台任务(日志记录、自检、空闲任务)

时间片轮转:同优先级任务的公平游戏

当多个任务优先级相同时,ThreadX 用时间片轮转来分配 CPU。每个任务跑完自己的时间片,就轮到下一个。

时间片单位是系统 tick。假设 tick 周期是 1ms,时间片设为 10,那这个任务每次最多跑 10ms。

场景 时间片设置 效果
任务执行时间短且均匀 5-10 ticks 响应快,切换开销适中
任务执行时间长 20-50 ticks 减少切换次数,提高吞吐
任务不需要轮转 0 不参与轮转,跑完才让出 CPU

避坑指南:时间片别设得太小。我曾经设成 1 tick,结果两个同优先级任务疯狂切换,上下文开销占了 CPU 的 30%。后来改成 10 ticks,问题解决。

知识结构总览

下面这张图把本章的核心知识点串起来了,建议你保存下来当参考:

ThreadX 任务管理知识体系 TX_THREAD 任务控制块 任务创建与删除 API tx_thread_create 参数配置 tx_thread_delete 安全删除 优先级机制 0(最高)~ 31(最低) 抢占式调度:高优先级就绪即抢 时间片轮转 同优先级任务轮流执行 time_slice = 0 不参与轮转 核心:控制块是基础,优先级定先后,时间片保公平

实战建议:任务设计三原则

说了这么多理论,最后分享三条我在项目中总结出来的经验:

  1. 优先级别乱用:能用 5 级解决的问题,别用 10 级。优先级越多,调度逻辑越复杂,越容易出优先级反转的问题。
  2. 时间片是双刃剑:它保证了公平,但也带来了切换开销。如果同优先级任务只有一个,时间片设 0 就行。
  3. 栈大小留余量:我习惯在计算出的栈大小上再加 20%。嵌入式系统里,栈溢出是最难排查的 bug 之一。

最后提醒一句:任务创建时如果用了 TX_DONT_START,记得在合适的时机调用 tx_thread_resume 来启动它。我见过有人忘了这一步,任务创建了但永远不跑,排查了半天才发现。