Arduino 多任务调度中 millis() 溢出导致逻辑错误的实战规避

一、问题背景

在嵌入式开发中,Arduino的millis()函数返回自板卡启动以来的毫秒数,类型为unsigned long(32位无符号整数)。其最大值约为4,294,967,295毫秒,约49.7天。当达到最大值后,下一次递增会回绕到0,即发生溢出。对于长期运行的系统,若直接使用millis()进行时间比较,如if (millis() - lastTime > interval),在溢出瞬间会导致逻辑错误,任务调度紊乱。

二、溢出原理剖析

millis()基于定时器中断累加,溢出时数值从0xFFFFFFFF变为0x00000000。若代码中采用if (millis() > lastTime + interval),当lastTime + interval超过0xFFFFFFFF时,结果回绕,比较结果错误。而if (millis() - lastTime > interval)利用无符号整数减法特性,即使溢出也能正确计算时间差,因为减法操作在无符号数中自动处理回绕。

三、实战规避方案

方案1:无符号减法比较(推荐)

核心思想:始终使用(millis() - lastTime)计算时间差,而非直接比较绝对值。

unsigned long lastTime = 0;
const unsigned long interval = 1000;

void loop() {
  unsigned long now = millis();
  if (now - lastTime >= interval) {
    lastTime = now;  // 更新基准时间
    // 执行任务
    Serial.println("Task executed");
  }
}

原理:无符号减法在溢出时自动回绕,例如lastTime = 0xFFFFFFF0now = 0x00000010,则now - lastTime = 0x20(32),正确表示时间差。

方案2:周期任务取模法

适用于固定周期任务,利用取模运算避免溢出比较。

unsigned long lastTime = 0;
const unsigned long period = 5000; // 5秒周期

void loop() {
  unsigned long now = millis();
  if (now % period < lastTime % period) { // 检测周期边界
    // 执行任务
  }
  lastTime = now;
}

注意:此方法要求period能整除0xFFFFFFFF+1(即2^32),否则周期会漂移。实际中不常用,仅作了解。

方案3:状态机设计

将任务分解为状态,用时间差驱动状态转换,避免直接比较时间戳。

enum State { IDLE, RUNNING, DONE };
State state = IDLE;
unsigned long startTime = 0;
const unsigned long timeout = 2000;

void loop() {
  switch (state) {
    case IDLE:
      startTime = millis();
      state = RUNNING;
      break;
    case RUNNING:
      if (millis() - startTime >= timeout) {
        // 执行动作
        state = DONE;
      }
      break;
    case DONE:
      // 清理并回到IDLE
      state = IDLE;
      break;
  }
}

四、完整示例:多任务调度器

以下代码实现两个任务:LED闪烁(500ms)和串口打印(1s),均使用无符号减法,安全应对溢出。

unsigned long lastLedTime = 0;
unsigned long lastPrintTime = 0;
const unsigned long ledInterval = 500;
const unsigned long printInterval = 1000;
bool ledState = false;

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
  Serial.begin(9600);
}

void loop() {
  unsigned long now = millis();
  
  // 任务1:LED闪烁
  if (now - lastLedTime >= ledInterval) {
    lastLedTime = now;
    ledState = !ledState;
    digitalWrite(LED_BUILTIN, ledState);
  }
  
  // 任务2:串口打印
  if (now - lastPrintTime >= printInterval) {
    lastPrintTime = now;
    Serial.print("Uptime (ms): ");
    Serial.println(now);
  }
}

五、注意事项

  • 始终使用无符号减法:避免millis() > lastTime + interval的写法,改用millis() - lastTime >= interval
  • 更新基准时间:在条件满足后,立即将lastTime更新为当前millis(),防止重复触发。
  • 避免使用delay():阻塞会破坏多任务调度,且影响溢出处理。
  • 数据类型一致性:确保所有时间变量均为unsigned long,避免隐式转换。
  • 测试溢出场景:可通过模拟millis()返回值(如用micros()或自定义计数器)验证代码在溢出时的行为。
  • 考虑micros():若需要更高精度,micros()同样存在溢出问题,处理方式相同。

六、总结

millis()溢出是嵌入式开发中的经典陷阱,但通过无符号减法比较,可以轻松规避。本文提供的三种方案中,方案1最简洁可靠,适用于大多数场景;方案3适合复杂状态逻辑。开发者应养成使用(millis() - lastTime)的习惯,并注意更新基准时间,从而构建长期稳定运行的系统。