超级循环(superloop)——一个 while(1) 依次调用每个任务——是我们大多数人在微控制器上写的第一个程序。它简单、可调试,对相当多的设备来说已经足够好。但只要有一个任务阻塞或运行时间过长,其他所有任务都会受到影响。我见过这在生产环境中出问题:一个偶尔耗时 50 ms 的传感器读取,会让一个需要每 5 ms 运行一次的控制循环挨饿,设备在现场表现异常。
这篇文章中,我想用真实数字展示这个问题,然后介绍不需要 RTOS 的修复方案——固定时间片调度器、非阻塞状态机和看门狗。以下所有代码都是真实的 C 代码,我在这台 Linux 主机上用 gcc -O2 编译并运行。测量使用 clock_gettime(CLOCK_MONOTONIC),输出真实的微秒级差值。
如果你对嵌入式网络不熟悉,我的文章 调试嵌入式 TCP 和 ESP32-C3 入门 涵盖了硬件方面的内容。这里我们聚焦在调度器层面。
超级循环及其适用场景
超级循环就是字面意思:
int main(void) {
hardware_init();
while (1) {
task_a();
task_b();
task_c();
}
}
每个任务运行到完成,然后下一个开始。没有抢占,没有优先级反转,没有上下文切换开销。对于一个只有少量任务的小型 MCU,这通常是正确的选择。你可以通过从上到下阅读来推理整个系统。
问题在于循环周期是所有任务时间的总和。如果 task_a 耗时 2 ms,task_b 耗时 5 ms,task_c 耗时 1 ms,那么循环每 8 ms 运行一次——任何需要比 8 ms 更紧截止期的任务都没戏了。
问题:抖动和漂移的真实测量
我写了一个包含三个任务的朴素超级循环,用 clock_gettime(CLOCK_MONOTONIC) 测量每个任务的实际周期:
static uint64_t now_us(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000ull + (uint64_t)ts.tv_nsec / 1000ull;
}
static void spin_us(uint64_t us) {
uint64_t start = now_us();
while (now_us() - start < us) {}
}
static void task_a(void) { spin_us(2000); } /* 2 ms 工作 */
static void task_b(void) { spin_us(5000); } /* 5 ms 工作 */
static void task_c(void) { spin_us(1000); } /* 1 ms 工作 */
以下是 20 次迭代后的真实输出:
Iter | A_period | B_period | C_period | Loop_total | Drift
-----+------------+------------+------------+-------------+--------
0 | 2000 us | 7000 us | 8000 us | 8000s | +0 us
1 | 8004 us | 8004 us | 8004 us | 8004s | +4 us
2 | 8004 us | 8004 us | 8004 us | 8004s | +4 us
3 | 8005 us | 8005 us | 8005 us | 8005s | +5 us
5 | 8003 us | 8003 us | 8003 us | 8003s | +3 us
10 | 8001 us | 8001 us | 8001 us | 8001s | +1 us
17 | 8014 us | 8014 us | 8014 us | 8014s | +14 us
19 | 8007 us | 8007 us | 8007 us | 8007s | +7 us
有两个问题很明显。第一,每个任务的周期相同——都是 8 ms——因为它们是顺序执行的。任务 A 并不是每 2 ms 运行一次;它每 8 ms 运行一次,等待 B 和 C。第二,存在真实抖动:循环总时间在 8001 到 8014 µs 之间变化,13 µs 的波动。这是定时器开销、缓存效应和 clock_gettime 调用本身造成的。在有中断处理器的真实 MCU 上,抖动会更严重。
如果任务 A 有 5 ms 的截止期,它每次迭代都会错过。这就是根本问题:在朴素超级循环中,最慢的任务决定了所有任务的周期。
修复 1:带截止期表的固定时间片调度器
第一个修复方案是给每个任务自己的周期,并跟踪它的截止时间。不再按顺序调用任务,而是维护一个截止期表,运行所有到期的任务:
typedef struct {
const char *name;
void (*func)(void);
uint64_t period_us;
uint64_t next_due;
uint64_t max_jitter;
uint64_t runs;
} task_t;
task_t tasks[] = {
{ "sensor", task_sensor, 10000, t0 + 10000, 0, 0 },
{ "control", task_control, 20000, t0 + 20000, 0, 0 },
{ "comm", task_comm, 5000, t0 + 5000, 0, 0 },
};
while (1) {
uint64_t now = now_us();
for (int i = 0; i < NUM_TASKS; i++) {
if (now >= tasks[i].next_due) {
tasks[i].func();
tasks[i].next_due += tasks[i].period_us;
}
}
}
我用 10 ms 的传感器任务、20 ms 的控制任务和 5 ms 的通信任务运行了这个程序。真实输出:
Tick | Task | Expected | Actual | Jitter | Total_runs
-----+----------+----------+----------+----------+-----------
0 | comm | 5000 us | 5428 us | +428 us | 1
1 | sensor | 10000 us | 10530 us | +530 us | 1
2 | comm | 5000 us | 7036 us | +2036 us | 2
3 | comm | 5000 us | 6037 us | +1037 us | 3
--- Summary ---
sensor : 1 runs, max jitter = 530 us
control : 0 runs, max jitter = 0 us
comm : 3 runs, max jitter = 2036 us
调度器工作了——任务按自己的周期运行——但看看抖动。通信任务(5 ms 周期)的抖动高达 2036 µs。为什么?因为传感器任务占用 1.5 ms 的 CPU 时间,当它运行时,会延迟通信任务的下次调用。控制任务(20 ms 周期)在这个短测试中没有运行,因为循环太忙了。
这就是关键洞察:固定时间片调度器不会消除抖动;它只是让抖动变得可见且有界。你现在可以测量它,而测量结果会告诉你截止期是否得到满足。
修复 2:协作式任务表
对许多项目来说,更清晰的模式是协作式任务表——一个函数指针数组,每个任务有一个下次运行时刻:
typedef struct {
const char *name;
void (*func)(void);
uint64_t period_us;
uint64_t next_run;
uint64_t run_count;
} coop_task_t;
coop_task_t tasks[] = {
{ "read_adc", task_read_adc, 5000, t0 + 5000, 0 },
{ "update_pwm", task_update_pwm, 2000, t0 + 2000, 0 },
{ "send_uart", task_send_uart, 10000, t0 + 10000, 0 },
{ "blink_led", task_blink_led, 500, t0 + 500, 0 },
};
while (1) {
uint64_t now = now_us();
for (int i = 0; i < NUM_TASKS; i++) {
if (now >= tasks[i].next_run) {
tasks[i].func();
tasks[i].next_run += tasks[i].period_us;
tasks[i].run_count++;
}
}
}
运行 50 ms 后的结果:
--- Final counts after 50 ms ---
read_adc : 9 runs (expected ~10)
update_pwm : 24 runs (expected ~25)
send_uart : 4 runs (expected ~5)
blink_led : 62 runs (expected ~100)
计数接近预期但不精确——循环开销和每个任务内部花费的时间会稍微偏移截止期。这是正常的。重要的是每个任务大致以自身的速率运行,慢任务不会阻止快任务运行。
修复 3:用非阻塞状态机替代 delay()
超级循环中最常见的 bug 是阻塞式 delay() 或 sleep()。当一个任务睡眠时,整个循环都冻结了。我写了一个演示程序,比较阻塞式延迟和非阻塞状态机在检测按钮按下时的差异:
/* 阻塞版本——错过事件 */
for (int i = 0; i < 20; i++) {
spin_us(500000); /* 500 ms 阻塞延迟 */
if (button_pressed(now)) { /* 延迟后才检查 */ }
}
/* 非阻塞版本——状态机 */
typedef enum { IDLE, DEBOUNCE, PRESSED } button_state_t;
button_state_t state = IDLE;
while (now_us() - t0 < 100000) {
bool raw = button_pressed(now);
switch (state) {
case IDLE:
if (raw) { state = DEBOUNCE; state_entry = now; }
break;
case DEBOUNCE:
if (now - state_entry >= 5000) {
if (raw) { state = PRESSED; detected = true; }
else state = IDLE;
}
break;
case PRESSED:
if (!raw) state = IDLE;
break;
}
spin_us(100); /* 每次循环做其他工作 */
}
真实输出说明了一切:
--- Blocking Version (delay-based) ---
Button press at t=30ms was MISSED (still in delay)
Detection latency: >500ms (deadline was 10ms)
--- Non-Blocking Version (state machine) ---
Button detected at t=35 ms
Detection latency: 5 ms from press (deadline was 10ms) — PASS
阻塞版本完全错过了按钮,因为按下时它还在 500 ms 的延迟中。非阻塞状态机在按下后 5 ms 检测到——在 10 ms 截止期内。这个差异不是微妙的;它是一个设备能响应和不能响应之间的差异。
规则很简单:永远不要在超级循环中调用阻塞延迟。如果你需要等待,使用一个检查时间、只在截止期过后才推进的状态机。
修复 4:看门狗踢狗
即使使用了非阻塞代码,任务仍然可能挂死——I2C 总线被拉低、内存分配永不返回、由损坏状态触发的无限循环。看门狗定时器在软件未在超时时间内踢狗时复位系统。
模式很直接:
typedef struct {
uint64_t timeout_us;
uint64_t last_kick;
uint64_t reset_count;
} watchdog_t;
watchdog_t wd;
watchdog_init(&wd, 100000); /* 100 ms 超时 */
for (int i = 0; i < 10; i++) {
watchdog_kick(&wd); /* 循环开始踢狗 */
task_that_might_hang(i); /* 正常 5ms,但迭代 5 挂死 500ms */
if (!watchdog_check(&wd)) { /* 任务后检查 */
/* 看门狗触发——系统会在这里复位 */
}
}
真实输出:
Iter | Task_time | Watchdog_status
-----+-----------+----------------
0 | 5000 us | OK
1 | 5000 us | OK
2 | 5000 us | OK
3 | 5000 us | OK
4 | 5000 us | OK
[TASK] Iteration 5: simulating hang (500ms spin)...
[TASK] Iteration 5: recovered
[WATCHDOG] Timeout! No kick for 500004 us — RESET #1
5 | 500004 us | RESET
6 | 5000 us | OK
7 | 5000 us | OK
8 | 5000 us | OK
9 | 5001 us | OK
Total watchdog resets: 1
看门狗恰好触发一次,当任务挂死 500 ms 时——是 100 ms 超时的五倍。在真实硬件上,看门狗会复位 MCU,系统会恢复。没有它,设备会永远挂死。
关键是在每次循环迭代开始时踢狗,而不是结束时。如果你在结束时踢狗,挂死的任务会阻止踢狗,看门狗会触发——这正是你想要的。如果你在开始时踢狗,你是在承诺”我将在超时时间内完成这次迭代”,看门狗让你信守这个承诺。
你真正需要 RTOS 的时候
我不是反 RTOS。有些问题超级循环确实无法解决:
-
带阻塞 API 的硬截止期。 如果你必须调用阻塞的 socket
read()或阻塞的 I2C 事务,而且你有一个比最坏情况阻塞时间更短的截止期,你需要抢占。RTOS 让你在低优先级任务阻塞时运行高优先级任务。 -
真正的并行。 如果你有两个必须以不同速率同时运行的控制循环,且两者都无法变成非阻塞的,带抢占式调度的 RTOS 是正确的工具。
-
复杂的同步。 互斥锁、信号量和消息队列在超级循环上很难正确实现。如果你的系统需要它们,RTOS 给你经过测试的原语。
但对于大多数小型嵌入式设备——传感器节点、电机控制器、简单 UI——带固定时间片调度器、非阻塞状态机和看门狗的超级循环更简单、更易调试,而且完全够用。RTOS 增加了复杂性:上下文切换、栈管理、优先级配置,以及一整类超级循环永远不会有的 bug(优先级反转、栈溢出、死锁)。
诚实建议:先测量
在选择 RTOS 之前,先测量你的抖动。在 Linux 上使用 clock_gettime(CLOCK_MONOTONIC),或在你的 MCU 上使用硬件定时器,记录每个任务的实际周期。计算最大抖动——最长周期和最短周期之间的差值。如果最大抖动远在截止期之内,你不需要 RTOS。如果不在,修复那个任务:让它变成非阻塞的,移到定时器中断中,或拆分成更小的步骤。
超级循环不是玩具。它是一个支撑数百万设备的合法架构。关键是理解它的局限——一个慢任务会拖垮其他任务——并应用保持其可靠的修复方案:固定时间片调度器让每个任务有自己的周期,非阻塞状态机让没有任务能冻结循环,看门狗让挂死任务不会挂死设备。测量抖动,修复你发现的问题,然后才考虑 RTOS 是否值得它的复杂性。