引言

在 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。但掌握这种轻量级调度思想,对理解嵌入式系统核心概念大有裨益。