引言

Arduino凭借其简易性成为原型开发首选,但面对复杂实时任务时,其默认的delay()millis()轮询方式往往力不从心。硬件定时器(如Timer1、Timer2)数量有限,且中断优先级固定,难以满足多任务差异化响应需求。本文面向有经验的开发者,探讨在Arduino上实现多优先级软定时器中断的替代方案,剖析其原理与陷阱,并给出一个健壮的软件实现。

为什么需要软定时器中断?

  • 硬件定时器通常只有2-3个,且多被Arduino核心库占用(如millis()使用Timer0)。
  • 中断优先级由硬件决定,无法动态调整,导致高实时性任务可能被低优先级中断阻塞。
  • 软定时器通过软件调度模拟多定时器,可灵活分配优先级,且不占用额外硬件资源。

替代方案概览

1. 基于millis()的非阻塞轮询

  • 原理:在主循环中不断检查millis()差值,达到设定时间则执行任务。
  • 优点:简单、无额外资源。
  • 缺点:无法实现真正中断,主循环阻塞时定时失效;优先级需手动管理,易出现长任务拖垮其他任务。

2. Ticker库(如Arduino Ticker)

  • 原理:利用一个硬件定时器(通常为Timer2)产生周期性中断,在中断中回调注册函数。
  • 优点:近似中断行为,支持多个定时任务。
  • 缺点:所有回调在中断上下文中执行,若回调耗时过长会阻塞其他中断;优先级仅靠注册顺序,无法抢占;且库实现可能使用delayMicroseconds等阻塞函数,引发不可预测延迟。

3. TimerOne/TimerThree库

  • 原理:直接配置硬件定时器,允许自定义中断服务函数(ISR)。
  • 优点:精确、可设置优先级(通过修改NVIC)。
  • 缺点:仅限特定定时器,且需手动管理多个定时器,代码复杂度高;在Arduino Uno上仅Timer1可用,多任务需软件扩展。

4. 自研软定时器调度器(推荐)

  • 原理:利用一个硬件定时器产生固定节拍(如1ms),在ISR中维护一个优先级队列,根据到期时间触发对应任务,任务在中断外或中断内执行(取决于设计)。
  • 优点:可灵活配置优先级、支持抢占(若任务在中断外执行,可通过调度器切换)、可扩展性强。
  • 缺点:实现复杂,需注意临界区保护。

陷阱分析

陷阱1:在ISR中执行耗时操作

  • 现象:Ticker回调中调用Serial.print()delay(),导致系统卡顿或丢失定时。
  • 原因:ISR应短小精悍,耗时操作会阻塞其他中断,且delay()依赖中断,在ISR中调用会死锁。
  • 解决:ISR仅设置标志位或写入队列,实际处理放在主循环。

陷阱2:优先级反转

  • 现象:低优先级任务长时间占用CPU,高优先级任务无法及时执行。
  • 原因:在非抢占式调度中,低优先级任务若在临界区,高优先级任务需等待。
  • 解决:使用抢占式调度(如基于状态机切换),或确保临界区极短。

陷阱3:定时器溢出与时间比较错误

  • 现象:使用millis()时,当计数器回绕(约49天)后,直接比较current - start > interval会出错。
  • 原因:无符号减法在溢出时仍正确,但若用有符号比较则失败。
  • 解决:使用(uint32_t)(current - start) < interval形式,或采用硬件定时器计数。

陷阱4:动态内存分配在ISR中

  • 现象:在ISR中使用newmalloc,导致堆碎片或崩溃。
  • 原因:ISR不可重入,且动态分配非线程安全。
  • 解决:预先分配静态内存或使用内存池。

实战:实现一个多优先级软定时器调度器

以下代码基于Arduino Uno,利用Timer2产生1ms节拍,维护一个固定大小的任务表,支持优先级(数值越小优先级越高)。任务在中断中仅标记到期,实际执行在主循环中,避免ISR过长。

原理

  • 使用Timer2配置为CTC模式,产生1ms中断。
  • 在ISR中遍历任务表,将到期任务的状态置为READY,并记录当前时间。
  • 主循环中,选择最高优先级的READY任务执行,执行完毕后清除状态。
  • 任务函数需为非阻塞,或采用状态机分段执行。

配置步骤

  1. 定义任务控制块(TCB)结构体。
  2. 初始化定时器2,设置比较匹配寄存器。
  3. 注册任务(设置周期、优先级、回调函数)。
  4. 在主循环中调用调度器。

完整代码示例

#include <Arduino.h>

#define MAX_TASKS 5
#define TICK_MS 1

// 任务状态
typedef enum {
    TASK_STOPPED,
    TASK_RUNNING,
    TASK_READY
} TaskState;

typedef struct {
    uint32_t period;      // 周期(ms)
    uint32_t last_run;    // 上次执行时间戳
    uint8_t priority;     // 优先级,0最高
    void (*callback)();   // 回调函数
    TaskState state;
} TCB;

static TCB tasks[MAX_TASKS];
static volatile uint32_t tick_count = 0;

// Timer2 ISR,1ms触发
ISR(TIMER2_COMPA_vect) {
    tick_count++;
    for (int i = 0; i < MAX_TASKS; i++) {
        if (tasks[i].state != TASK_STOPPED) {
            if ((uint32_t)(tick_count - tasks[i].last_run) >= tasks[i].period) {
                tasks[i].state = TASK_READY;
                tasks[i].last_run = tick_count;
            }
        }
    }
}

// 初始化定时器2
void init_timer2() {
    cli();
    TCCR2A = 0;
    TCCR2B = 0;
    TCNT2 = 0;
    OCR2A = (F_CPU / 1024 / 1000) - 1; // 1ms @16MHz, prescaler 1024
    TCCR2A |= (1 << WGM21); // CTC模式
    TCCR2B |= (1 << CS22) | (1 << CS21) | (1 << CS20); // 1024分频
    TIMSK2 |= (1 << OCIE2A); // 使能比较匹配中断
    sei();
}

// 注册任务
bool add_task(uint8_t index, uint32_t period, uint8_t priority, void (*cb)()) {
    if (index >= MAX_TASKS || cb == NULL) return false;
    tasks[index].period = period;
    tasks[index].priority = priority;
    tasks[index].callback = cb;
    tasks[index].last_run = tick_count;
    tasks[index].state = TASK_RUNNING; // 初始为运行态,但实际未执行
    return true;
}

// 调度器:主循环调用
void scheduler_run() {
    int highest_pri = -1;
    int task_index = -1;
    // 查找最高优先级READY任务
    for (int i = 0; i < MAX_TASKS; i++) {
        if (tasks[i].state == TASK_READY) {
            if (highest_pri == -1 || tasks[i].priority < highest_pri) {
                highest_pri = tasks[i].priority;
                task_index = i;
            }
        }
    }
    if (task_index != -1) {
        tasks[task_index].state = TASK_RUNNING;
        tasks[task_index].callback(); // 执行任务
        // 若任务为一次性,可停止;否则保持运行态,等待下次到期
        // 这里假设任务周期执行,执行后状态由ISR下次置READY
        tasks[task_index].state = TASK_RUNNING; // 重新置为运行,等待下次
    }
}

// 示例任务
void task_high() {
    static uint8_t led_state = 0;
    digitalWrite(13, led_state ^= 1); // 翻转LED
}

void task_low() {
    // 模拟耗时任务,但非阻塞
    static uint32_t last = 0;
    if (millis() - last > 500) {
        Serial.println("Low pri task");
        last = millis();
    }
}

void setup() {
    Serial.begin(9600);
    pinMode(13, OUTPUT);
    init_timer2();
    add_task(0, 100, 0, task_high); // 100ms周期,优先级0
    add_task(1, 1000, 1, task_low); // 1000ms周期,优先级1
}

void loop() {
    scheduler_run();
    // 其他主循环工作
}

注意事项

  • 临界区保护:在修改任务表或访问tick_count时,应暂时关闭中断(cli()/sei()),防止数据竞争。
  • 任务执行时间:若任务执行时间超过其周期,会错过下一次触发,需设计为可重入或状态机。
  • 优先级抢占:上述实现为非抢占式,若需抢占,可在ISR中直接执行高优先级任务,但需确保任务短小。
  • 定时器精度OCR2A计算基于16MHz,若使用其他板子需调整分频和比较值。
  • 内存使用:任务表静态分配,避免动态内存。

总结

Arduino上的多优先级软定时器实现需权衡资源与实时性。Ticker库简单但易踩坑,自研调度器虽复杂但可控性强。本文提供的方案通过节拍中断与主循环调度分离,有效避免ISR过长问题,并支持优先级选择。开发者应根据实际需求,选择合适方案,并时刻警惕中断上下文陷阱,确保系统稳定可靠。