Arduino 无 OS 协作式调度器:定时器中断驱动的优先级反转规避实战
👁 2 阅读 · 2026-08-27 · 嵌入式
在资源受限的 Arduino 平台上,无操作系统的协作式调度器是提升实时性的利器。然而,基于定时器中断的抢占式切换常引发优先级反转,导致任务饿死。本文深入剖析该问题的根源,并给出一种轻量级、可移植的规避方案:通过中断标志位与任务状态机结合,实现非抢占式协作调度,同时利用定时器中断进行时间片监控,在关键区外安全切换。文章包含原理图解、完整代码示例及调试注意事项,助你在裸机环境下构建稳定、可预测的多任务系统。
# 引言
在 Arduino 生态中,`loop()` 循环天然是顺序执行的,但真实项目往往需要同时处理传感器采样、通信协议、按键扫描等任务。引入 RTOS 又显得臃肿,此时协作式调度器成为理想折中:任务主动让出 CPU,代码简单、内存占用极低。但若借助定时器中断实现时间片轮转,则可能触发优先级反转——低优先级任务持有共享资源时被中断,高优先级任务等待该资源,而中优先级任务抢占了 CPU,导致高优先级任务被无限期阻塞。
本文将基于 Arduino Uno(ATmega328P)演示一种规避技巧:**协作式任务 + 定时器中断仅做时间基准,不直接抢占**,通过任务状态机与临界区保护,从根源上消除反转。
# 原理剖析
## 协作式调度的核心
协作式调度依赖任务主动让出(`yield()`),每个任务运行至完成或显式挂起。其优点是无上下文切换开销、无数据竞争(若任务间不共享变量)。但缺点是无法响应紧急事件——若某任务长时间运行,其他任务会饿死。
## 定时器中断的陷阱
若用定时器中断强制切换任务(如 `setjmp/longjmp` 或直接修改 PC),则形成抢占式调度。此时,若低优先级任务正在修改共享缓冲区,中断触发切换到高优先级任务,高优先级任务访问同一缓冲区,而中优先级任务恰好就绪,则高优先级任务被中优先级任务间接阻塞,形成反转。
## 规避策略:中断标志 + 协作切换
我们采用**时间片监控 + 协作切换**:
- 定时器中断仅设置一个全局标志 `task_tick`,不执行切换。
- 每个任务在循环中检查 `task_tick`,若超时则主动让出。
- 任务间共享数据通过 `cli()`/`sei()` 或原子操作保护,确保中断不会破坏临界区。
这样,中断只提供时间基准,不改变执行流,因此不存在抢占式反转。但需注意:若任务在临界区内超时,则不能立即让出,需先完成临界区,这可能导致时间片延长,但不会反转。
# 实现步骤
## 1. 定义任务控制块(TCB)
```c
#define MAX_TASKS 4
typedef struct {
void (*func)(void); // 任务函数指针
uint16_t period; // 周期(ms)
uint16_t elapsed; // 已运行时间
uint8_t active; // 是否启用
} Task_t;
Task_t tasks[MAX_TASKS];
volatile uint8_t task_tick = 0; // 中断置1,主循环清零
```
## 2. 定时器配置(使用 Timer1,1ms 中断)
```c
void setup_timer() {
cli();
TCCR1A = 0;
TCCR1B = (1 << WGM12) | (1 << CS11); // CTC模式,8分频
OCR1A = 1999; // 16MHz/8/2000 = 1kHz
TIMSK1 = (1 << OCIE1A);
sei();
}
ISR(TIMER1_COMPA_vect) {
task_tick = 1; // 仅置标志,不切换
}
```
## 3. 调度器核心
```c
void scheduler_run() {
while (1) {
for (uint8_t i = 0; i < MAX_TASKS; i++) {
if (!tasks[i].active) continue;
tasks[i].elapsed += 10; // 假设每次循环约10ms(实际需校准)
if (tasks[i].elapsed >= tasks[i].period) {
tasks[i].elapsed = 0;
tasks[i].func(); // 执行任务
}
// 任务内主动让出,但此处不强制
}
}
}
```
但上述代码是轮询式,不是协作式。真正的协作式需要任务内部调用 `yield()`。我们改进:
```c
volatile uint8_t yield_request = 0;
void yield() {
yield_request = 1;
while (yield_request) { /* 等待调度器清除 */ }
}
void scheduler_run() {
while (1) {
for (uint8_t i = 0; i < MAX_TASKS; i++) {
if (!tasks[i].active) continue;
yield_request = 0;
tasks[i].func(); // 任务内部会调用yield()
// 若任务未调用yield,则继续执行下一个(但可能阻塞)
}
}
}
```
## 4. 任务编写示例
```c
void task_led() {
static uint8_t state = 0;
digitalWrite(13, state);
state = !state;
yield(); // 主动让出
}
void task_sensor() {
int val = analogRead(A0);
// 处理数据...
yield();
}
```
## 5. 规避优先级反转的关键:临界区保护
当任务需要访问共享资源(如串口、I2C)时,必须关闭中断或使用原子操作:
```c
void safe_uart_send(char c) {
cli(); // 进入临界区
Serial.write(c);
sei();
}
```
注意:`yield()` 不能在临界区内调用,否则会死锁。因此,任务应设计为:
- 快速获取资源,释放后再让出。
- 若资源被占用,则设置标志并立即让出,等待下次轮询。
# 完整示例代码
```c
#include
#define MAX_TASKS 3
typedef struct {
void (*func)(void);
uint16_t period;
uint16_t elapsed;
uint8_t active;
} Task_t;
Task_t tasks[MAX_TASKS];
volatile uint8_t task_tick = 0;
volatile uint8_t yield_request = 0;
void yield() {
yield_request = 1;
while (yield_request) { /* 等待 */ }
}
void task_led() {
static uint8_t state = 0;
digitalWrite(13, state);
state = !state;
yield();
}
void task_print() {
static uint16_t count = 0;
cli();
Serial.print("Count: ");
Serial.println(count++);
sei();
yield();
}
void task_delay() {
// 模拟耗时操作,但内部让出
for (uint8_t i = 0; i < 10; i++) {
delay(1); // 注意:delay会阻塞,但这里仅演示
yield();
}
}
void setup() {
Serial.begin(9600);
pinMode(13, OUTPUT);
// 初始化任务表
tasks[0] = {task_led, 500, 0, 1};
tasks[1] = {task_print, 1000, 0, 1};
tasks[2] = {task_delay, 2000, 0, 1};
// 配置定时器
cli();
TCCR1A = 0;
TCCR1B = (1 << WGM12) | (1 << CS11);
OCR1A = 1999;
TIMSK1 = (1 << OCIE1A);
sei();
}
ISR(TIMER1_COMPA_vect) {
task_tick = 1;
}
void loop() {
// 调度器主循环
static uint16_t tick_count = 0;
if (task_tick) {
task_tick = 0;
tick_count++;
for (uint8_t i = 0; i < MAX_TASKS; i++) {
if (!tasks[i].active) continue;
tasks[i].elapsed += 1;
if (tasks[i].elapsed >= tasks[i].period) {
tasks[i].elapsed = 0;
yield_request = 0;
tasks[i].func();
}
}
}
}
```
# 注意事项
- **定时器精度**:上述代码中 `elapsed` 以1ms为增量,但 `loop()` 中执行任务会占用时间,导致时间片漂移。建议在任务函数开头记录 `millis()`,用真实时间差更新 `elapsed`。
- **临界区长度**:`cli()`/`sei()` 之间的代码应尽量短,否则影响中断响应。
- **yield() 死循环**:若调度器未清除 `yield_request`,任务会卡死。确保在任务调用前清零。
- **任务优先级**:协作式调度本身无优先级,但可通过任务顺序模拟。若需严格优先级,可改为事件驱动。
- **中断服务函数**:ISR 中只做标志位操作,避免调用 `Serial` 等非中断安全函数。
# 总结
本文通过定时器中断仅做时间基准、任务主动让出的协作式设计,从机制上避免了优先级反转。此方案适用于任务数量少、实时性要求不苛刻的场景。若需硬实时,可考虑引入优先级继承协议或使用真正的 RTOS。但掌握这种轻量级调度思想,对理解嵌入式系统核心概念大有裨益。