1. 调度器基础概念:什么是任务调度器?为什么需要它?裸机编程与调度器的对比

大家好,欢迎来到这门课的第一章。咱们今天聊点最基础,但也最容易被忽视的东西——任务调度器。

我见过不少工程师,写了好几年单片机程序,还是靠一个大循环加中断打天下。不是说不行,但项目一旦复杂起来,这种“裸奔”方式会让你焦头烂额。今天我们就来掰扯清楚:调度器到底是什么?为什么你需要它?

1.1 什么是任务调度器?

说白了,任务调度器就是一个“管家”。它负责决定:什么时候,该让哪个任务运行,运行多久。

在单片机世界里,你的程序通常由多个功能模块组成:读传感器、处理数据、刷新屏幕、响应按键、通信收发……如果没有调度器,这些模块就得靠你自己手动安排执行顺序。

调度器干的事情,可以归纳为三点:

  • 任务管理:创建、删除、挂起、恢复任务
  • 上下文切换:保存当前任务的状态,切换到另一个任务
  • 调度策略:按优先级、时间片或事件来决定谁先跑

核心观点:调度器不是操作系统,它只是一个“任务切换引擎”。你可以把它看作一个轻量级的协作或抢占式多任务框架。

我记得刚入行那会儿,觉得调度器这东西是高端货,只有跑 Linux 的大芯片才用得上。后来做一个小型物联网终端,8 位单片机,RAM 只有 2KB,硬是靠一个协作式调度器把 7 个任务跑得稳稳当当。从那以后,我对调度器的看法彻底变了。

1.2 为什么需要调度器?

你可能会问:“我一个大循环 while(1) 加几个定时器中断,不也能干活吗?”

嗯,能干活,但干得累。咱们来看几个真实场景:

  1. 实时性要求:按键响应必须在 10ms 内完成,而传感器采集需要 50ms。大循环里,如果传感器采集卡住了,按键就失灵了。
  2. 代码复杂度:当功能模块超过 5 个,大循环里的状态机就开始像意大利面条一样纠缠不清。
  3. 可维护性:半年后你自己回头看代码,可能都分不清哪个中断改了哪个全局变量。
  4. 功耗控制:没有调度器,你很难精确控制 CPU 什么时候休眠、什么时候唤醒。

我的经验:我曾经接手过一个项目,前任工程师用裸机写了 3000 多行代码,全部挤在一个 while(1) 里。加一个新功能,改了 3 天,崩了 5 次。后来我花了一天时间把调度器移植进去,把功能拆成 6 个任务,代码量反而少了 800 行,而且再也没出过时序问题。

1.3 裸机编程 vs 调度器:一个直观对比

咱们用一张表格来对比,这样更清楚:

对比维度 裸机编程(大循环+中断) 使用调度器
任务管理 手动状态机,全局变量满天飞 每个任务独立函数,数据隔离
实时性 依赖中断优先级,长任务会阻塞 可抢占或时间片轮转,响应可控
代码可读性 逻辑分散在循环和中断中 任务边界清晰,模块化强
调试难度 改一个变量可能影响全局 任务独立,问题定位快
资源开销 极低(几乎为零) 需要额外 RAM/ROM(通常 1-4KB)
扩展性 加功能容易破坏原有时序 加任务不影响其他任务
功耗管理 难以精确控制休眠时机 空闲任务统一管理休眠

你看,裸机编程的优势只有一个:资源开销低。但现在的单片机,哪怕是最便宜的 STM32F0 或 ATmega328,也有几 KB 的 RAM 和几十 KB 的 Flash。拿出 1-2KB 换一个清晰、稳定、可扩展的架构,这笔账怎么算都划算。

1.4 两种调度器类型:协作式 vs 抢占式

调度器不是只有一种。咱们先简单区分两个主流类型,后面章节会深入讲:

  • 协作式调度器:任务主动让出 CPU。每个任务必须自己调用“我干完了,下一个上”的函数。好处是简单、资源占用极低;坏处是如果一个任务死循环,整个系统就挂了。
  • 抢占式调度器:由定时器中断强制切换任务。每个任务有固定时间片,时间到了就被打断。好处是实时性强,坏处是需要处理临界区、共享资源保护等问题。

避坑指南:我曾经在一个电机控制项目里用了协作式调度器,结果一个任务里不小心写了 while(flag==0); 等待硬件标志位,结果硬件没响应,整个系统卡死。从那以后,凡是涉及硬件等待的任务,我都会加超时保护,或者干脆用抢占式调度器。

1.5 调度器的核心知识体系

下面这张图,是我自己总结的调度器知识框架。你可以把它当作整个课程的地图:

调度器核心知识体系 任务调度器 调度策略 任务管理 同步与通信 资源管理 协作式 抢占式 创建/删除 挂起/恢复 信号量 消息队列 互斥锁 临界区 测试与验证(本课程核心) 单元测试 集成测试 压力测试 边界测试 课程主线 从基础概念 → 调度器实现 → 测试用例设计 → 压力测试 → 实战优化 每个环节都配有可运行的代码示例和测试脚本

1.6 一个简单的调度器代码骨架

光说不练假把式。咱们来看一个最简协作式调度器的核心代码。这个例子只有 30 多行,但包含了调度器的基本思想:

/* 最简协作式调度器骨架 */
#define MAX_TASKS  5

typedef struct {
    void (*func)(void);   // 任务函数指针
    uint32_t interval;    // 执行间隔(ms)
    uint32_t last_tick;   // 上次执行时间戳
    uint8_t  active;      // 是否启用
} task_t;

static task_t tasks[MAX_TASKS];
static uint8_t task_count = 0;

// 注册任务
int task_register(void (*func)(void), uint32_t interval_ms) {
    if (task_count >= MAX_TASKS) return -1;
    tasks[task_count].func = func;
    tasks[task_count].interval = interval_ms;
    tasks[task_count].last_tick = 0;
    tasks[task_count].active = 1;
    task_count++;
    return 0;
}

// 调度器主循环
void scheduler_run(void) {
    uint32_t now;
    while (1) {
        now = get_tick_ms();  // 获取系统滴答
        for (uint8_t i = 0; i < task_count; i++) {
            if (!tasks[i].active) continue;
            if (now - tasks[i].last_tick >= tasks[i].interval) {
                tasks[i].last_tick = now;
                tasks[i].func();   // 执行任务
            }
        }
        // 空闲时让 CPU 休眠
        __WFI();  // 等待中断
    }
}

注意:这个调度器是协作式的,每个任务必须快速返回,不能阻塞。如果某个任务执行时间超过其间隔,就会导致后续任务延迟。后面章节我们会讲怎么解决这个问题。

1.7 什么时候不该用调度器?

说了这么多调度器的好处,我也得泼点冷水。不是所有场景都适合上调度器:

  • 极端资源受限:比如 1KB RAM、8KB Flash 的 4 位或 8 位单片机,跑调度器可能连任务控制块都放不下。
  • 极简功能:只有一个传感器采集 + 一个 LED 闪烁,裸机几行代码搞定,没必要引入调度器。
  • 硬实时要求极高:某些电机控制或电源转换应用,要求中断响应在微秒级,调度器的上下文切换开销可能成为瓶颈。

但话说回来,我做了十几年嵌入式,90% 的项目最终都上了调度器。因为项目总是在迭代,功能总是在增加。今天你觉得裸机够用,半年后可能就后悔了。