教程 · 入门 · 30 分钟

为什么 delay() 会让机器人失灵:用 millis() 计时

被阻塞的循环什么也看不见。delay() 会让机器人付出多少毫米的代价、替代它的 millis() 写法,以及显而易见的改法里藏着的溢出 bug。

引言

本站几乎每一台机器人的循环里都有几件必须同时做的事:读传感器、更新控制器、驱动电机、也许还要回无线。delay() 是每个人最先学的时序工具,也正是它让上面这一切变得不可能。

问题不在于 delay() 慢。而在于在 delay() 期间机器人什么都不在做——不读传感器、不转向、注意不到自己就要撞墙了。那是一个自愿停下来的程序。

这一页讲它实际的代价、替代它的那个模式,以及那个显而易见的替代方案仍然带有的一个缺陷。

delay() 到底在做什么

delay(50) 在一个紧凑的循环里盯着定时器空转,直到 50 ms 过去。你的代码不前进。仍然发生的只有中断——驱动 millis() 的那个定时器、串口接收中断、以及任何你用 attachInterrupt() 挂上去的东西。

这个例外既重要也是个陷阱:一个在中断里计数的编码器在 delay() 期间照样在数,于是数字是对的,而机器人的行为停住了。这让故障更难看见,而不是更容易。

代价折算成毫米

控制环只能以它采样的速率作出反应。某样东西刚好在一次读之后出现,就要等到下一次才被注意到。

220 毫秒内的两条堆叠时间线。上面一条标注“读之间延时 50 毫秒”,传感器读数是每 50 毫秒一个高标记,间隙被着色并标注为阻塞、别的都不跑。下面一条标注“毫秒计时、5 毫秒循环”,读数每 5 毫秒密集排列。一条红色虚线标出障碍在 51 毫秒处出现;上面那条时间线在 49 毫秒后才注意到,下面那条在 4 毫秒后。
障碍在一次读之后立刻出现,这对两个循环来说都接近最坏情况——而这正是要紧的数字,因为控制环是按最坏延迟评判的,不是按平均。0.5 m/s 下,被阻塞的机器人要走 24 mm 才知道;轮询的那个走 2 mm。 下载 SVG

二十四毫米差不多是循迹车传感器阵列的一大半。它也足以让一台迷宫车冲出格子、冲过路口,或者把一台平衡车推过可恢复的角度——那台车需要 200 Hz 的内环,也就是一切加起来 5 ms 的预算,而一个 delay(50) 就花掉了十轮的量。延迟永远是一个距离——机器人在能动作之前走了多远决定了它是及时停住还是冲下边缘。

替代它的模式

不要停下来等到时间到,而是每一轮都问一句到时间了没有:

unsigned long lastRead = 0;
const unsigned long READ_INTERVAL = 50;

void loop() {
  unsigned long now = millis();

  if (now - lastRead >= READ_INTERVAL) {
    lastRead = now;
    readSensor();
  }

  // everything else keeps running, every pass
  updateMotors();
  checkBumper();
}

现在 loop() 每秒跑几千次,而传感器读在它里面按时刻发生。什么都没有被阻塞,而且加第二件定时活只是多四行,而不是重写一遍。

回绕缺陷

millis() 返回一个 unsigned long——在 AVR 上是 32 位——数的是开机以来的毫秒数。它在 2³² ms 处用完,然后回绕到零,也就是49.7 天。

比较的同一种写法有两种,而只有一种能活过那一刻:

if (now - lastRead >= READ_INTERVAL)   // correct
if (now >= lastRead + READ_INTERVAL)   // wrong

取 lastRead = 4294967290、间隔 100 ms,就在回绕前一刻。lastRead + 100 溢出成 94,而 now >= 94 立刻为真——于是定时器不再等待,每一轮都触发,直到计数器追上为止。相减的那种写法没有这个问题,因为无符号减法的回绕方式恰好让差值算出来是对的。

由此有三条规则:

  • 永远相减,永不相加。now - last >= interval。
  • **所有时间戳都存成 unsigned long。**把 millis() 存进 int 或有符号 long 会立刻把这个缺陷请回来,而 AVR 上的 int 32 秒就回绕。
  • 永远不要测相等。now == target 是抛硬币;忙的一轮会直接跨过去,事件永远不触发。

micros() 值得单独警告一次。它在 2³² 微秒处回绕,也就是 71.6 分钟——对一台机器人来说是完全正常的运行时长。规则相同,来得早得多。

多件事,不同的频率

一旦有了两三件以上的定时活,给它们一张表,而不是一堆变量:

struct Task {
  unsigned long last;
  unsigned long interval;
  void (*run)();
};

Task tasks[] = {
  { 0,   5, readSensors   },
  { 0,  10, updateControl },
  { 0, 200, reportStatus  },
};

void loop() {
  unsigned long now = millis();
  for (Task &t : tasks) {
    if (now - t.last >= t.interval) {
      t.last = now;
      t.run();
    }
  }
}

这就是一个协作式调度器,而它是大多数机器人永远需要的一切。有两点要注意:

t.last = now 是从任务实际运行的时刻重新起表,所以晚了一轮会让下一轮也晚,节奏慢慢漂掉。t.last += t.interval 则保持节奏——对控制环更好,但如果某个任务偶尔超出它的间隔就更糟,因为那时它会试图追赶、连着背靠背地跑。对时序要紧的那个循环用 +=,其余用 = now。

而且整件事都建立在“没有任务会阻塞”上。reportStatus() 里面一个 delay(),其他每个任务都跟着晚。

阻塞仍然是对的时候

不是每一次等待都是错误。判据是它是否短于一轮循环。

  • 用 delayMicroseconds(10) 打 HC-SR04 的触发脉冲没问题。十微秒什么都不算,而且没有非阻塞的办法产生它。
  • **pulseIn() 不行。**它会阻塞到回波回来或者超时到期。一台工作在 4 m 限距的 HC-SR04 是 8 m 往返,按 343 m/s 算是 23 ms——而一次漏掉的回波,代价就是你设定的那个完整超时。这就是那篇教程坚持要设超时的原因。
  • **Serial.print() 藏着一个延时。**64 字节缓冲区还有空间时它立刻返回,满了就阻塞。9600 波特率下缓冲区要 67 ms 才排空,所以在 100 Hz 下每轮打印一行 40 字符,绝对会把循环拖住。用 115200,少打印,并且放在慢定时器上打印。

你的循环时间是个真数

别再靠猜:

unsigned long worst = 0;

void loop() {
  unsigned long t0 = micros();

  // ... the whole loop body ...

  unsigned long dt = micros() - t0;
  if (dt > worst) worst = dt;   // report this on a slow timer, not here
}

要跟的是最坏那一轮,不是平均值。平均值告诉你循环跑得宽裕;最坏值告诉你控制环真正必须承受什么,而它通常是一次 Serial.print、一次 pulseIn 超时,或者一次不得不等待的 I²C 读。

这对 PID 控制器最要紧。微分项用误差变化除以两次采样之间的时间,所以那个间隔一抖,D 上就直接变成噪声——而在某个循环频率下调好的增益,换到另一个频率就不再对。要么把循环跑在固定间隔上、把 dt 当常数,要么每轮量一次 dt、用真实值。两样都不做,就是为什么一台在台架上表现良好的控制器,一加上无线电代码就行为异常。

该拿什么代替

代替 用
用 delay() 控制传感器读取节奏 millis() 间隔判断
用 delay() 闪一个 LED 同一个循环里的一个定时器
用 delay() 等舵机到位 估算行程时间,然后判断 millis()
用 delay() 给按键消抖 给边沿打时间戳,20 ms 内忽略变化
用 delay() 分隔电机速度步进 由流逝时间驱动的运动曲线
用 delay() 把整个循环拖慢 什么都不要——跑快,然后各自计时
无超时的 pulseIn() pulseIn(pin, HIGH, 30000) 加一个“无回波”分支

出问题时

现象 通常原因
转弯时机器人不理保险杠 转弯里有 delay()
传感器读数成批到来 所有东西共用一个定时器,而不是各自的间隔
台架上好,加上日志就变慢 缓冲区满了之后 Serial.print 阻塞
约 50 天后定时器连续触发 用了 now >= last + interval 那种写法
约一小时后定时器行为异常 micros() 在 71.6 分钟处回绕
定时器完全不触发 时间戳存成了 int,或者用了相等判断
PID 调好了行为却变了 循环频率变了;dt 不是增益假设的那个值
编码器计数对,车反应却晚 delay() 期间中断还在跑——问题不在计数

值得养成的习惯是把 loop() 当成必须永远快速返回的东西。一旦做到这一点,加一个功能就是加一个任务,而机器人的反射动作保持不变。一旦做不到,每加一个功能就让机器人瞎一点点,而原因从不在症状所在的地方。

探索关联图谱

本教程所属的搭建

包含本教程的项目和学习路线。

延伸阅读

参考资料