os操作系统——第2讲:任务的三六九等

目录

    • 第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 必须执行就绪链表中优先级最高的那个任务。如果新任务优先级高于当前任务,立即抢占。

这听起来简单,但实现时有两个关键点:

  1. 何时检查抢占?

    • 定时器中断结束时(调度点)。
    • 任务主动释放CPU(比如调用 sleep 或等待锁时)。
    • 中断/系统调用使一个更高优先级的任务变为就绪(例如延时到期)。
  2. 如何高效找到最高优先级任务?

    • 对于少量任务(< 32),可以用一个 优先级位图 (bitmap),每个任务一个bit,__builtin_clz(前导零计数)秒级查找。
    • 对于一般教学,直接用优先级数组 + 循环扫描也可以。

我们先实现一个简单的优先级就绪链表数组

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(&current_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. 本讲小结 & 下集预告

今天我们实现了操作系统的两大核心机制:

  1. TCB (任务控制块):结构化描述任务的所有属性(栈、优先级、状态、延时信息)。
  2. 状态机:就绪、运行、阻塞的状态转移,以及基于优先级的抢占调度。

有了这两样东西,我们才算真正拥有了一个完整的实时内核雏形。接下来一个问题呼之欲出:

"当多个任务同时访问同一个全局变量时,如果没有保护,结果会怎样?"

第3讲:让任务学会"等待"------临界区与关中断保护

我们将揭示数据错乱的根源(Race Condition),并学习关中断、锁等基本同步机制。这也是迈向健壮操作系统的必经之路。


✍️ 思考与练习

  1. 画状态图 :画出 os_sleep 被调用后,任务从运行 → 阻塞 → 就绪 → 运行的状态变迁,标出每个变迁发生的条件。
  2. 思考优先级反转:如果低优先级任务占用了某个共享资源(如锁),而此时高优先级任务想要同一资源,会发生什么?(这是第5讲内容,可以先思考)
  3. 动手实验:修改第一讲的 bare-metal 代码,加入 TCB 结构体和基于优先级的调度器。增加两个任务,一个优先级高,一个优先级低,高优先级任务无限循环,观察低优先级任务能否得到运行机会。如果不加时间片,会发生"饥饿"吗?

期待你在评论区分享你的 TCB 设计思路和实验现象!

相关推荐
山木嵌入式7 小时前
FreeRTOS从入门到进阶:核心概念与调度原理全解析
stm32·操作系统·嵌入式·freertos·rtos
一支闲人20 小时前
Free RTOS:信号量实验
freertos
济6171 天前
FreeRTOS看门狗任务设计---软件看门狗 + 硬件 IWDG 双保险实现
嵌入式·freertos
山木嵌入式3 天前
FreeRTOS任务创建全解析:动态/静态创建+实战案例+参数深度剖析
stm32·freertos
济6174 天前
FreeRTOS日志任务设计----LogTask 日志任务
单片机·嵌入式·freertos
济6174 天前
FreeRTOS教程----队列详解
嵌入式·freertos
温中志6 天前
esp_event_loop_create_default详细解释
esp32·freertos
济6177 天前
FreeRTOS 系统监控任务设计(上篇) ---MonitorTask的 基础框架
单片机·嵌入式·freertos
济6177 天前
MonitorTask 系统监控任务(下篇)---完善堆内存 、任务栈监控
单片机·嵌入式·freertos