目录
-
- 第2讲:任务的三六九等------TCB(任务控制块)与任务状态机
- [1. 为什么需要 TCB?](#1. 为什么需要 TCB?)
- [2. TCB 的设计(最简单版本)](#2. TCB 的设计(最简单版本))
- [3. 任务状态机:每个任务的一生](#3. 任务状态机:每个任务的一生)
- [4. 调度器升级:基于优先级的抢占](#4. 调度器升级:基于优先级的抢占)
- [5. 阻塞延时:让出 CPU 的优雅方式](#5. 阻塞延时:让出 CPU 的优雅方式)
- [6. 完整的数据结构与调度器示例(伪代码整合)](#6. 完整的数据结构与调度器示例(伪代码整合))
- [7. 实验结果:同一个 LED,不再死等](#7. 实验结果:同一个 LED,不再死等)
- [8. 本讲小结 & 下集预告](#8. 本讲小结 & 下集预告)
- [✍️ 思考与练习](#✍️ 思考与练习)
第2讲:任务的三六九等------TCB(任务控制块)与任务状态机
上一讲我们用定时器中断粗暴地实现了两个任务之间的"乒乓切换"。但那个代码里,每个任务只是一个裸函数,调度器只知道"下一个是谁"。
如果系统里有 10 个任务,有的需要紧急响应,有的只是后台统计,有的正在等待数据...... 再这样"轮流坐庄"就太简陋了。
这一讲,我们将引入操作系统的第一个核心数据结构------任务控制块(TCB) ,并建立任务的状态机。从此,每个任务不再是平等的"二等公民",而是有了自己的身份、状态和优先级。
1. 为什么需要 TCB?
回想第一讲,我们管理任务用的是两个零散的变量:
c
unsigned int *sp1, *sp2; // 每个任务的栈指针
如果需要增加第三个任务,就得手动再定义一个栈,并在切换逻辑里硬编码 if-else。这显然不可扩展。
更重要的是,调度器在做决策时,需要知道每个任务的额外信息:
- 这个任务叫什么?(方便调试)
- 它的优先级是多少?(紧急任务优先)
- 它目前是在运行、就绪,还是在等某个信号?
- 它已经运行了多长时间?(时间片管理)
- 它需要多少栈空间?(防止溢出)
这些信息打包在一起,就是 TCB(Task Control Block) 。
对于操作系统内核来说,TCB 就是"任务的身份证"。
2. TCB 的设计(最简单版本)
一个极简的 TCB 可以这样定义:
c
typedef struct tcb {
// 必须字段
unsigned int *sp; // 任务的栈指针 (上下文保存位置)
struct tcb *next; // 用于链表
// 扩展字段
unsigned int priority; // 优先级 (数值越小优先级越高)
unsigned int state; // 任务状态
unsigned int sleep_ticks; // 延时阻塞剩余滴答数
char name[16]; // 任务名,便于调试
} tcb_t;
其中 state 可以取以下几个值:
c
#define TASK_RUNNING 0 // 正在运行
#define TASK_READY 1 // 就绪,可被调度
#define TASK_BLOCKED 2 // 阻塞,等待事件或延时
#define TASK_SUSPENDED 3 // 挂起(不参与调度,需手动唤醒)
真实操作系统(如 FreeRTOS)还会有"就绪态挂起"、"阻塞态挂起"等细分,但核心就是这四大状态。
3. 任务状态机:每个任务的一生
我们用一个状态机图来描述任务从创建到销毁的完整生命周期:
创建任务
调度器选择
时间片用完/被抢占
等待事件/延时
事件发生/延时到期
主动/外部挂起
主动/外部挂起
主动/外部挂起
恢复(resume)
恢复(resume)
任务结束/销毁
任务销毁
任务销毁
任务销毁
就绪态
运行态
阻塞态
挂起态
解读:
- 就绪 → 运行:调度器从就绪链表中选出优先级最高的任务,运行它。
- 运行 → 就绪:时间片用完,或者来了一个更高优先级的任务(抢占),当前任务被迫让出CPU,回到就绪队列尾部。
- 运行 → 阻塞 :任务主动调用
sleep()或等待信号量/队列,此时它不再占用CPU,被移入阻塞链表。 - 阻塞 → 就绪:等待的条件满足(例如延时时间到,或者信号量被释放),任务重新回到就绪链表,等待调度。
- 挂起(Suspend) :是一种主动冻结 ,无论任务处于什么状态(除了已销毁),都可以被外部命令挂起。挂起后,即使等待的事件发生,它也不会转为就绪,必须显式
resume。
为了让第一专栏节奏适中,本讲我们先实现"就绪"、"运行"、"阻塞(延时)"三个状态。挂起和优先级继承等留到后面。
4. 调度器升级:基于优先级的抢占
上一讲的时间片轮转是"人人平等",现在引入优先级后,规则变成:
任何时候,CPU 必须执行就绪链表中优先级最高的那个任务。如果新任务优先级高于当前任务,立即抢占。
这听起来简单,但实现时有两个关键点:
-
何时检查抢占?
- 定时器中断结束时(调度点)。
- 任务主动释放CPU(比如调用
sleep或等待锁时)。 - 中断/系统调用使一个更高优先级的任务变为就绪(例如延时到期)。
-
如何高效找到最高优先级任务?
- 对于少量任务(< 32),可以用一个 优先级位图 (bitmap),每个任务一个bit,
__builtin_clz(前导零计数)秒级查找。 - 对于一般教学,直接用优先级数组 + 循环扫描也可以。
- 对于少量任务(< 32),可以用一个 优先级位图 (bitmap),每个任务一个bit,
我们先实现一个简单的优先级就绪链表数组:
c
#define MAX_PRIORITY 8
tcb_t *ready_list[MAX_PRIORITY]; // 每个优先级一个链表(通常用双向链表,这里简化为单向)
tcb_t *current_task;
调度核心伪代码:
c
tcb_t *get_next_task(void) {
// 从高优先级往低优先级搜索第一个非空链表
for (int prio = 0; prio < MAX_PRIORITY; prio++) {
if (ready_list[prio] != NULL) {
tcb_t *task = ready_list[prio];
// 这里简单取出链表头部(轮转由中断决定)
ready_list[prio] = task->next;
task->next = NULL;
return task;
}
}
return NULL; // 不应该发生,至少有空闲任务
}
抢占时机 :每次定时器中断结束时,我们调用 schedule(),它会比较当前任务和就绪队列中最高优先级的任务。如果新任务的优先级 > 当前任务优先级,就执行切换;否则,把当前任务放回就绪队列(时间片轮转)或继续运行(如果它是唯一最高优先级)。
c
void schedule(void) {
tcb_t *next = get_next_task();
if (next == NULL) return;
if (next->priority < current_task->priority) { // 数值越小优先级越高
// 抢占:保存当前任务,换入 next
switch_context(¤t_task, next);
} else {
// 当前任务继续运行,但为了时间片轮转,可以把当前任务移到就绪队列尾部
// 具体实现看设计(这里先略)
}
}
5. 阻塞延时:让出 CPU 的优雅方式
第一讲里我们用的 delay_ms() 是"忙等待",浪费 CPU。有了状态机后,我们可以实现真正的非阻塞延时 :任务调用 os_sleep(ticks),将自己从就绪链表摘下,放入阻塞链表(按唤醒时间排序),然后立即触发调度,CPU 去执行其他任务。
阻塞链表可以用一个按唤醒时间排列的有序链表,或者用一个简单的"延时滴答计数器"数组。我们按最简单的实现:
- 每个 TCB 增加一个字段
sleep_ticks(剩余需要阻塞的时钟滴答数)。 - 当任务调用
sleep(ticks)时,设置sleep_ticks = ticks,将自己标记为TASK_BLOCKED,并从就绪链表移除。 - 每个定时器中断(tick)遍历所有阻塞任务,对每个任务的
sleep_ticks减1,减到0的任务变为TASK_READY,重新插入就绪链表(根据优先级插入正确位置)。
这种遍历方式当任务数量多时效率低(O(n)),但适合教学。真实的 RTOS 会用"延时列表 + 时间轮"优化。
6. 完整的数据结构与调度器示例(伪代码整合)
下面给出一个可工作的骨架(基于 ARM Cortex-M,但你可以在任何模拟器上跑)。
c
// tcb.h
typedef struct tcb {
unsigned int *sp;
struct tcb *next;
unsigned int priority;
unsigned int state;
unsigned int sleep_ticks;
char name[16];
} tcb_t;
// global variables
tcb_t *ready_lists[MAX_PRIORITY];
tcb_t *blocked_list; // 简单单向链表,按阻塞时间排序(这里为了简单,不排序)
tcb_t *current_task;
void os_sleep(int ticks) {
if (ticks <= 0) return;
enter_critical();
current_task->sleep_ticks = ticks;
current_task->state = TASK_BLOCKED;
// 将当前任务从 ready_list 中取出(需要知道它在哪个优先级链表里,这里略)
// 插入阻塞链表
current_task->next = blocked_list;
blocked_list = current_task;
exit_critical();
// 主动让出CPU
schedule();
}
// 在定时器中断中调用
void tick_handler(void) {
// 处理阻塞队列
tcb_t *prev = NULL;
tcb_t *t = blocked_list;
while (t) {
if (t->sleep_ticks > 0) {
t->sleep_ticks--;
}
if (t->sleep_ticks == 0 && t->state == TASK_BLOCKED) {
// 唤醒任务:从阻塞链表中摘除,插入就绪链表
// (链表操作略)
t->state = TASK_READY;
// 插入到 ready_lists[t->priority] 头部或尾部(取决于调度策略)
}
prev = t;
t = t->next;
}
// 最后调用 scheduler 决定是否抢占
schedule();
}
注意:以上代码为演示核心思想,实际使用时需要考虑临界区保护,因为定时器中断和任务代码会并发访问 TCB 链表。(这正是第3讲的内容。)
7. 实验结果:同一个 LED,不再死等
假设我们有三个任务:
- 高优先级任务:紧急处理传感器数据(每 5ms 运行一次,很快结束)
- 中优先级任务:普通计算任务(长时间运行)
- 低优先级任务:打印调试信息(慢速)
在优先级抢占调度下,高优先级任务可以立即打断中/低优先级任务,保证了实时性。而 os_sleep 让中低优先级任务在等待期间不再霸占 CPU。
可以试着在模拟器上观察:LED 闪烁的频率不再受 delay 长短的绝对影响,因为 CPU 在等待时可以去跑别的任务。
8. 本讲小结 & 下集预告
今天我们实现了操作系统的两大核心机制:
- TCB (任务控制块):结构化描述任务的所有属性(栈、优先级、状态、延时信息)。
- 状态机:就绪、运行、阻塞的状态转移,以及基于优先级的抢占调度。
有了这两样东西,我们才算真正拥有了一个完整的实时内核雏形。接下来一个问题呼之欲出:
"当多个任务同时访问同一个全局变量时,如果没有保护,结果会怎样?"
第3讲:让任务学会"等待"------临界区与关中断保护
我们将揭示数据错乱的根源(Race Condition),并学习关中断、锁等基本同步机制。这也是迈向健壮操作系统的必经之路。
✍️ 思考与练习
- 画状态图 :画出
os_sleep被调用后,任务从运行 → 阻塞 → 就绪 → 运行的状态变迁,标出每个变迁发生的条件。 - 思考优先级反转:如果低优先级任务占用了某个共享资源(如锁),而此时高优先级任务想要同一资源,会发生什么?(这是第5讲内容,可以先思考)
- 动手实验:修改第一讲的 bare-metal 代码,加入 TCB 结构体和基于优先级的调度器。增加两个任务,一个优先级高,一个优先级低,高优先级任务无限循环,观察低优先级任务能否得到运行机会。如果不加时间片,会发生"饥饿"吗?
期待你在评论区分享你的 TCB 设计思路和实验现象!