3. 任务与任务控制块:任务的概念、任务状态与TCB结构体
大家好,欢迎来到第三章。这一章我们聊聊任务本身。说白了,实时操作系统里最核心的东西就是任务。你写的所有代码,最终都要包装成一个一个的任务,交给内核去调度。
我记得刚入行那会儿,总把任务和函数搞混。觉得不就是个死循环嘛,有什么特别的?后来被坑了几次才明白——任务是有“生命”的,它有自己的状态、自己的栈、自己的“身份证”。这个身份证,就是任务控制块(TCB)。
3.1 任务到底是什么?
任务,在RTOS里也叫线程。它是一个独立的执行流,有自己的栈空间,有自己的优先级。你可以把它理解成一个“独立的小人”,这个小人一直在做某件事。
举个例子。一个PIS系统里,可能有这些任务:
- 显示刷新任务:每隔50ms刷新一次屏幕
- 按键扫描任务:检测用户有没有按按钮
- 通信处理任务:处理串口或CAN总线来的数据
- 心跳检测任务:每隔1秒让LED闪一下
这些任务看起来是“同时”运行的。但实际上,CPU只有一个。是操作系统在快速切换,让你感觉它们在并行。嗯,这里要注意——任务切换是有代价的,切换太频繁反而会降低效率。
3.2 任务的四种状态
任务不是一直在跑的。它会在不同状态之间跳来跳去。RTOS里,任务通常有四种状态:
| 状态 | 说明 | 通俗理解 |
|---|---|---|
| 就绪(Ready) | 任务已经准备好,随时可以运行 | 排队等着叫号 |
| 运行(Running) | 任务正在占用CPU执行 | 正在被服务 |
| 阻塞(Blocked) | 任务在等待某个事件(如延时、信号量) | 在座位上等餐 |
| 挂起(Suspended) | 任务被强制暂停,不参与调度 | 被叫出去罚站 |
为什么会这样分?你想想看,如果所有任务都在抢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:链表指针。所有就绪任务串成一个链表,方便调度器遍历。
3.4 任务创建与初始化
有了TCB结构体,我们怎么创建一个任务?大致流程是这样的:
- 分配一块内存给TCB(可以从静态数组或动态堆里取)
- 分配一块内存给任务栈
- 初始化TCB的各个字段
- 把任务加入就绪链表
代码示例:
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),进入阻塞。 - 调度器发现显示任务优先级更高,切回显示任务。
你看,就这么几个来回,任务状态就在就绪、运行、阻塞之间跳来跳去。挂起态用得少一些,一般是在调试或者低功耗模式下才会用到。
3.6 本章小结
这一章我们聊了三个核心概念:
- 任务:独立的执行流,有自己的栈和优先级
- 任务状态:就绪、运行、阻塞、挂起,四种状态来回切换
- 任务控制块(TCB):任务的身份证,存了栈指针、优先级、状态等关键信息
下一章我们会深入调度器的实现。到时候你就知道,操作系统是怎么从就绪链表里挑出下一个任务来跑的。嗯,那才是真正精彩的部分。
记住一句话:理解了TCB,你就理解了RTOS的一半。剩下的,都是围绕TCB转的算法和机制。