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,问题解决。
知识结构总览
下面这张图把本章的核心知识点串起来了,建议你保存下来当参考:
实战建议:任务设计三原则
说了这么多理论,最后分享三条我在项目中总结出来的经验:
- 优先级别乱用:能用 5 级解决的问题,别用 10 级。优先级越多,调度逻辑越复杂,越容易出优先级反转的问题。
- 时间片是双刃剑:它保证了公平,但也带来了切换开销。如果同优先级任务只有一个,时间片设 0 就行。
- 栈大小留余量:我习惯在计算出的栈大小上再加 20%。嵌入式系统里,栈溢出是最难排查的 bug 之一。
最后提醒一句:任务创建时如果用了 TX_DONT_START,记得在合适的时机调用 tx_thread_resume 来启动它。我见过有人忘了这一步,任务创建了但永远不跑,排查了半天才发现。