第三章 实时操作系统(RTOS)基础:任务、调度器与核心机制

各位同学,欢迎来到第三章。从这一章开始,我们正式进入 RTOS 的世界。很多工程师刚接触 RTOS 时,会觉得它很神秘——不就是个“小系统”吗?其实不然。RTOS 的核心思想,说白了就是“在正确的时间,做正确的事”。我做了这么多年嵌入式,见过太多因为 RTOS 使用不当导致的“玄学”问题,其实根源都在于对这几个基础概念理解不透彻。

好,我们直接切入正题。这一章,我们只讲五件事:任务、调度器、上下文切换、临界区、任务状态。把这五件事搞明白,RTOS 的大门就算被你踹开了。

3.1 任务:RTOS 的“基本工作单元”

任务(Task),在有些 RTOS 里也叫线程(Thread)。它是什么?它就是一个无限循环的函数,加上它自己的栈空间和任务控制块(TCB)。

我个人习惯把任务想象成一个“独立的小人”。每个小人都有自己的“脑子”(栈,用来记临时数据)和“身份证”(TCB,用来记录任务优先级、状态等信息)。这些小人轮流在 CPU 这个“舞台”上表演。

看一个典型的任务代码结构:

void my_task(void *param)
{
    // 初始化硬件或外设
    init_sensor();

    while (1)
    {
        // 任务主体:等待事件、处理数据、输出结果
        wait_for_event();
        process_data();
        send_result();

        // 主动让出 CPU,或者等待下一次触发
        task_delay(100);  // 单位通常是毫秒
    }
}

注意看,任务函数里是一个 while(1) 死循环。为什么?因为任务一旦返回,RTOS 就会认为它“跑完了”,然后把它干掉。所以,任务函数永远不能返回。这是新手最容易犯的错误——我见过有人直接在任务里写 return,结果任务跑一次就消失了,系统直接挂掉。

⚠️ 避坑指南: 任务栈大小要仔细估算。我曾经在一个项目中,因为栈开得太小,任务跑着跑着就栈溢出了,数据被悄悄改写,查了三天才定位到问题。建议:任务栈至少留出 20% 的余量。

3.2 调度器:谁来决定下一个谁上场?

调度器(Scheduler)是 RTOS 的“大脑”。它的职责很简单:从所有就绪的任务中,选一个出来运行。

调度器常用的策略有两种:

调度策略 核心规则 典型场景
优先级抢占式调度 高优先级任务就绪时,立即抢占低优先级任务 大多数实时控制系统
时间片轮转调度 同优先级任务轮流运行固定时间片 非实时或弱实时系统

我个人最常用的是优先级抢占式调度。为什么?因为实时性要求高的场景,你必须保证紧急任务能“插队”。比如一个电机控制任务,优先级必须高于一个串口打印任务。否则电机堵转了,CPU 还在那打印日志,你说急不急人?

但这里有个陷阱——优先级反转。低优先级任务拿着高优先级任务需要的资源,导致高优先级任务被阻塞。嗯,这个问题我们后面讲互斥量时会详细说,这里先留个印象。

3.3 上下文切换:RTOS 的“换人”功夫

上下文切换(Context Switch),就是 CPU 从执行一个任务切换到执行另一个任务的过程。这个过程是 RTOS 最核心、也最消耗时间的操作之一。

切换时发生了什么?说白了就是“保存现场,恢复现场”:

  1. 保存当前任务的寄存器(R0-R15、PSR、PC、LR 等)到它的栈中
  2. 保存当前任务的栈指针到它的 TCB 中
  3. 从新任务的 TCB 中取出栈指针
  4. 从新任务的栈中恢复寄存器
  5. 跳转到新任务继续执行

这个过程通常由硬件触发(PendSV 异常)或软件主动调用(任务切换函数)完成。在 Cortex-M 系列 MCU 上,硬件已经帮我们做了大部分工作,但软件部分仍然需要精心优化。

💡 性能提示: 上下文切换的时间是 RTOS 性能的关键指标。我建议你在选型时,实际测量一下目标 RTOS 在目标 MCU 上的切换时间。方法很简单:在两个任务之间来回切换,用 GPIO 翻转来测量。如果切换时间超过 10 微秒(对于 100MHz 以上的 MCU),那这个 RTOS 的实时性可能不太够。

3.4 临界区:保护共享资源的“安全屋”

临界区(Critical Section),就是一段不能被中断或任务切换打断的代码。为什么需要它?因为多个任务可能同时访问同一个全局变量或外设寄存器,如果不加保护,数据就会乱掉。

我举个例子。假设两个任务都在修改一个全局变量 counter++。这个操作在 C 语言里是一行,但在汇编里可能是三条指令:读、加、写。如果任务 A 刚读完,任务 B 就插进来把值改了,然后任务 A 再写回去——那任务 B 的修改就被覆盖了。这就是经典的“读-改-写”问题。

临界区的保护方式通常有两种:

保护方式 原理 开销 适用场景
关中断 进入临界区前关全局中断,退出时开中断 低(几微秒) 临界区代码极短(< 10 条指令)
使用互斥量/信号量 任务获取锁才能进入临界区 中(几十微秒) 临界区代码较长,或可能阻塞

我个人建议:能用互斥量就别关中断。关中断虽然快,但会影响系统的实时响应。你想想看,如果你关中断关了 100 微秒,那这 100 微秒里所有中断都进不来——包括那个紧急的电机过流保护中断。后果?可能电机就烧了。

🔑 关键原则: 临界区要“短、平、快”。进去,干完活,马上出来。不要在临界区里做延时、等待、打印等耗时操作。

3.5 任务状态:就绪、运行、阻塞、挂起

一个任务在它的生命周期里,会经历不同的状态。理解这些状态,是理解 RTOS 行为的基础。

常见的任务状态有四种:

  • 就绪(Ready):任务已经准备好运行,只等调度器选中它。说白了就是“排队等 CPU”。
  • 运行(Running):任务正在占用 CPU 执行代码。同一时刻,只有一个任务处于运行态。
  • 阻塞(Blocked):任务在等待某个事件(如延时到期、信号量可用、消息队列有数据)。此时任务不参与调度。
  • 挂起(Suspended):任务被显式暂停,通常由其他任务或中断调用挂起函数触发。需要显式恢复才能继续。

我画了一张状态转换图,帮你理清它们之间的关系:

RTOS 任务状态转换图 RTOS 任务状态转换图 就绪 (Ready) 运行 (Running) 阻塞 (Blocked) 挂起 (Suspended) 调度器选中 被抢占/时间片用完 等待事件 事件到达 被挂起 被恢复 说明:同一时刻只有一个任务处于运行态;就绪态任务排队等待调度器选中;阻塞态任务不参与调度

你看这张图,任务状态的转换其实很清晰。我特别想强调一点:阻塞态和挂起态的区别。很多新手搞混这两个状态。

  • 阻塞是任务主动等待某个条件(比如延时、信号量),条件满足后自动回到就绪态。
  • 挂起是任务被被动暂停(由其他任务或中断调用挂起函数),必须由别人显式恢复。

举个例子。你写了一个串口接收任务,它调用 queue_receive() 等待数据。如果队列为空,任务就进入阻塞态。等数据来了,它自动回到就绪态。这是阻塞。而挂起呢?比如你有一个调试任务,平时不需要运行,你可以把它挂起。等需要调试时,再恢复它。

💡 实用技巧: 在调试阶段,我经常用挂起/恢复功能来“冻结”某个任务,观察系统行为。比如怀疑某个任务导致系统卡顿,就把它挂起,看系统是否恢复正常。这是定位问题的一个好方法。

3.6 本章小结

好,我们来快速回顾一下这一章的核心内容:

  • 任务是 RTOS 的基本工作单元,有自己的栈和 TCB,永远不能返回。
  • 调度器负责从就绪任务中选一个运行,优先级抢占式调度是最常用的策略。
  • 上下文切换是保存/恢复任务现场的过程,其耗时直接影响系统实时性。
  • 临界区保护共享资源不被并发访问,原则是“短、平、快”。
  • 任务状态包括就绪、运行、阻塞、挂起,理解状态转换是掌握 RTOS 行为的关键。