3、内核基础:任务、调度器、上下文切换的核心概念

各位同学,今天我们聊聊实时内核最核心的三个东西:任务、调度器、上下文切换。这三个概念,说白了就是操作系统的「三驾马车」。你搞懂了它们,实时内核的大门就算被你踹开了。

我刚开始接触 RTOS 时,总觉得这些东西很玄乎。后来在项目里被坑过几次,才真正明白它们的分量。嗯,咱们一个一个来拆解。

3.1 任务:操作系统的最小执行单元

任务是什么?简单说,就是一段独立运行的代码,有自己的栈、优先级、状态。在裸机编程里,你只有一个 main 函数无限循环。但在 RTOS 里,你可以创建多个任务,让它们「看起来」在同时运行。

我个人习惯把任务想象成一个「独立的小人」——每个小人都有自己的活要干,有自己的小本本(栈)记录干到哪了。

3.1.1 任务的状态机

一个任务在生命周期里,会经历几种状态。我画了个表格,你一看就明白:

状态 说明 典型触发条件
就绪(Ready) 任务已准备好运行,等待调度器分配 CPU 任务创建完成、等待事件超时
运行(Running) 任务正在占用 CPU 执行代码 调度器选中该任务
阻塞(Blocked) 任务因等待某事件而暂停 等待信号量、消息队列、延时
挂起(Suspended) 任务被强制暂停,不参与调度 调用任务挂起 API

我在项目中遇到过一个问题:有个任务明明优先级很高,但就是跑不起来。查了半天,发现它被不小心挂起了。嗯,这种低级错误,犯过一次就记住了。

3.1.2 任务栈:每个任务的私有空间

每个任务都有自己的栈。为什么?因为函数调用、局部变量、中断现场,都得有个地方存。如果两个任务共用栈,那不乱套了?

我曾经在项目里把任务栈设得太小,结果程序跑着跑着就莫名其妙地死机了。后来用调试器一看,栈溢出了。所以我的建议是:任务栈大小至少留出 30% 的余量。别抠门,栈空间不值钱,死机排查才要命。

核心要点:任务栈大小 = 最大调用深度 × 每个函数栈帧大小 + 中断嵌套栈空间 + 30% 安全余量

3.2 调度器:谁该干活了?

调度器,说白了就是操作系统的「交通警察」。它决定下一个该哪个任务上 CPU 跑。实时内核的调度器,跟 Linux 那种「公平调度」完全不一样。实时调度器追求的是:确定性

你想想看,工业控制里,一个传感器数据必须在 1ms 内处理完。如果调度器说「大家轮流来,公平一点」,那这个 1ms 的 deadline 就完蛋了。

3.2.1 优先级抢占式调度

这是最常用的实时调度策略。规则很简单:

  • 每个任务有一个优先级(数字越小优先级越高,或者反过来,看具体内核)
  • 高优先级任务就绪时,立即抢占低优先级任务的 CPU
  • 同优先级任务之间,通常采用时间片轮转

我记得有个项目,客户要求系统响应时间不超过 100 微秒。我们用了优先级抢占调度,把关键中断处理任务的优先级设到最高。效果立竿见影。

我的经验:优先级不要设太多层级。一般 8-16 级就够用了。层级太多,调度器的开销反而变大,得不偿失。

3.2.2 调度点:什么时候触发调度?

调度不是随时发生的。它只在特定「调度点」被触发:

  1. 任务主动让出 CPU(比如调用延时或等待 API)
  2. 中断退出时
  3. 高优先级任务被唤醒时
  4. 时间片用完时(如果支持时间片轮转)

这里有个坑:如果你在中断服务函数里唤醒了一个高优先级任务,调度不会立即发生,而是等到中断退出时才进行。这叫「中断延迟调度」。我曾经在这个地方吃过亏——以为唤醒后马上就能跑,结果被中断处理拖了几微秒。

3.3 上下文切换:换人干活,得保存现场

上下文切换,就是从一个任务切换到另一个任务时,保存当前任务的「现场」,恢复下一个任务的「现场」。这个「现场」包括:

  • CPU 寄存器(通用寄存器、程序计数器、栈指针等)
  • 浮点寄存器(如果支持浮点运算)
  • 一些特殊寄存器(比如 MPU 配置)

你想想看,如果切换时不保存现场,任务 A 算到一半的变量值,被任务 B 覆盖了,那不就全乱套了?

3.3.1 上下文切换的代价

上下文切换不是免费的。每次切换都要:

  1. 保存当前任务的寄存器到它的栈中
  2. 从就绪队列中找到下一个要运行的任务
  3. 恢复下一个任务的寄存器
  4. 跳转到下一个任务的执行位置

这个过程,在 ARM Cortex-M 上大概需要几十到几百个 CPU 周期。听起来不多,但如果你的系统里每秒切换几千次,那开销就相当可观了。

注意:上下文切换时间,是衡量 RTOS 性能的关键指标。我见过一些商用 RTOS,切换时间能做到 1 微秒以内。但有些「玩具级」RTOS,切换一次要几十微秒。选型时一定要看这个数据。

3.3.2 PendSV:ARM Cortex-M 的上下文切换利器

在 ARM Cortex-M 处理器上,上下文切换通常通过 PendSV 异常来实现。为什么不用普通的 SVCall?因为 PendSV 的优先级可以设到最低,这样它就不会打断其他中断服务。

流程是这样的:

1. 任务 A 正在运行
2. 某个事件触发了调度(比如任务 B 就绪了)
3. 触发 PendSV 异常(优先级最低)
4. 当前中断或任务执行完毕后,PendSV 开始执行
5. PendSV 里保存任务 A 的现场,恢复任务 B 的现场
6. 从 PendSV 返回,直接跳到任务 B 继续执行

我个人觉得,PendSV 的设计非常巧妙。它保证了中断响应不会被上下文切换拖慢。嗯,这就是为什么 Cortex-M 在实时控制领域这么流行。

3.4 避坑指南:我踩过的那些坑

讲了这么多理论,来点实战经验。以下是我在项目中真实遇到过的坑:

  • 栈溢出检测:我曾经在一个电机控制项目里,任务栈只设了 256 字节。结果一跑起来就死机。后来加了栈溢出检测钩子函数,才发现是某个函数递归调用太深。从那以后,我每个任务都加栈溢出检测。
  • 优先级反转:低优先级任务占着资源不放,高优先级任务等得干着急。解决方案是优先级继承协议。我在一个数据采集项目里遇到过,加了优先级继承后,系统响应时间从 5ms 降到了 0.5ms。
  • 调度器锁:有些新手喜欢在中断里调用调度器锁。千万别!调度器锁只能在任务级代码里用。我在代码评审时见过这种写法,直接打回去重写。

总结一下:任务、调度器、上下文切换,这三个概念是实时内核的基石。任务负责干活,调度器决定谁干活,上下文切换保证干活不串场。搞懂了它们,你就掌握了 RTOS 的核心。

下一章,我们会深入讲任务间通信和同步。到时候你会看到,信号量、消息队列这些东西,其实都是建立在今天这些基础之上的。嗯,先把基础打牢,后面就顺了。