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 调度点:什么时候触发调度?
调度不是随时发生的。它只在特定「调度点」被触发:
- 任务主动让出 CPU(比如调用延时或等待 API)
- 中断退出时
- 高优先级任务被唤醒时
- 时间片用完时(如果支持时间片轮转)
这里有个坑:如果你在中断服务函数里唤醒了一个高优先级任务,调度不会立即发生,而是等到中断退出时才进行。这叫「中断延迟调度」。我曾经在这个地方吃过亏——以为唤醒后马上就能跑,结果被中断处理拖了几微秒。
3.3 上下文切换:换人干活,得保存现场
上下文切换,就是从一个任务切换到另一个任务时,保存当前任务的「现场」,恢复下一个任务的「现场」。这个「现场」包括:
- CPU 寄存器(通用寄存器、程序计数器、栈指针等)
- 浮点寄存器(如果支持浮点运算)
- 一些特殊寄存器(比如 MPU 配置)
你想想看,如果切换时不保存现场,任务 A 算到一半的变量值,被任务 B 覆盖了,那不就全乱套了?
3.3.1 上下文切换的代价
上下文切换不是免费的。每次切换都要:
- 保存当前任务的寄存器到它的栈中
- 从就绪队列中找到下一个要运行的任务
- 恢复下一个任务的寄存器
- 跳转到下一个任务的执行位置
这个过程,在 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 的核心。
下一章,我们会深入讲任务间通信和同步。到时候你会看到,信号量、消息队列这些东西,其实都是建立在今天这些基础之上的。嗯,先把基础打牢,后面就顺了。