引言
在 Arduino 生态中,loop() 循环天然是顺序执行的,但真实项目往往需要同时处理传感器采样、通信协议、按键扫描等任务。引入 RTOS 又显得臃肿,此时协作式调度器成为理想折中:任务主动让出 CPU,代码简单、内存占用极低。但若借助定时器中断实现时间片轮转,则可能触发优先级反转——低优先级任务持有共享资源时被中断,高优先级任务等待该资源,而中优先级任务抢占了 CPU,导致高优先级任务被无限期阻塞。
本文将基于 Arduino Uno(ATmega328P)演示一种规避技巧:协作式任务 + 定时器中断仅做时间基准,不直接抢占,通过任务状态机与临界区保护,从根源上消除反转。
原理剖析
协作式调度的核心
协作式调度依赖任务主动让出(yield()),每个任务运行至完成或显式挂起。其优点是无上下文切换开销、无数据竞争(若任务间不共享变量)。但缺点是无法响应紧急事件——若某任务长时间运行,其他任务会饿死。
定时器中断的陷阱
若用定时器中断强制切换任务(如 setjmp/longjmp 或直接修改 PC),则形成抢占式调度。此时,若低优先级任务正在修改共享缓冲区,中断触发切换到高优先级任务,高优先级任务访问同一缓冲区,而中优先级任务恰好就绪,则高优先级任务被中优先级任务间接阻塞,形成反转。
规避策略:中断标志 + 协作切换
我们采用时间片监控 + 协作切换:
- 定时器中断仅设置一个全局标志
task_tick,不执行切换。 - 每个任务在循环中检查
task_tick,若超时则主动让出。 - 任务间共享数据通过
cli()/sei()或原子操作保护,确保中断不会破坏临界区。
这样,中断只提供时间基准,不改变执行流,因此不存在抢占式反转。但需注意:若任务在临界区内超时,则不能立即让出,需先完成临界区,这可能导致时间片延长,但不会反转。
实现步骤
1. 定义任务控制块(TCB)
#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 中断)
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. 调度器核心
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()。我们改进:
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. 任务编写示例
void task_led() {
static uint8_t state = 0;
digitalWrite(13, state);
state = !state;
yield(); // 主动让出
}
void task_sensor() {
int val = analogRead(A0);
// 处理数据...
yield();
}
5. 规避优先级反转的关键:临界区保护
当任务需要访问共享资源(如串口、I2C)时,必须关闭中断或使用原子操作:
void safe_uart_send(char c) {
cli(); // 进入临界区
Serial.write(c);
sei();
}
注意:yield() 不能在临界区内调用,否则会死锁。因此,任务应设计为:
- 快速获取资源,释放后再让出。
- 若资源被占用,则设置标志并立即让出,等待下次轮询。
完整示例代码
#include <Arduino.h>
#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。但掌握这种轻量级调度思想,对理解嵌入式系统核心概念大有裨益。