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里的优先级字段和链表指针是怎么被调度器玩出花样的。嗯,敬请期待。