教程 · 入门 · 30 分钟
为什么 delay() 会让机器人失灵:用 millis() 计时
被阻塞的循环什么也看不见。delay() 会让机器人付出多少毫米的代价、替代它的 millis() 写法,以及显而易见的改法里藏着的溢出 bug。
引言
本站几乎每一台机器人的循环里都有几件必须同时做的事:读传感器、更新控制器、驱动电机、也许还要回无线。delay() 是每个人最先学的时序工具,也正是它让上面这一切变得不可能。
问题不在于 delay() 慢。而在于在 delay() 期间机器人什么都不在做——不读传感器、不转向、注意不到自己就要撞墙了。那是一个自愿停下来的程序。
这一页讲它实际的代价、替代它的那个模式,以及那个显而易见的替代方案仍然带有的一个缺陷。
delay() 到底在做什么
delay(50) 在一个紧凑的循环里盯着定时器空转,直到 50 ms 过去。你的代码不前进。仍然发生的只有中断——驱动 millis() 的那个定时器、串口接收中断、以及任何你用 attachInterrupt() 挂上去的东西。
这个例外既重要也是个陷阱:一个在中断里计数的编码器在 delay() 期间照样在数,于是数字是对的,而机器人的行为停住了。这让故障更难看见,而不是更容易。
代价折算成毫米
控制环只能以它采样的速率作出反应。某样东西刚好在一次读之后出现,就要等到下一次才被注意到。
二十四毫米差不多是循迹车传感器阵列的一大半。它也足以让一台迷宫车冲出格子、冲过路口,或者把一台平衡车推过可恢复的角度——那台车需要 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 上的int32 秒就回绕。 - 永远不要测相等。
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() 当成必须永远快速返回的东西。一旦做到这一点,加一个功能就是加一个任务,而机器人的反射动作保持不变。一旦做不到,每加一个功能就让机器人瞎一点点,而原因从不在症状所在的地方。
探索关联图谱
本教程所属的搭建
包含本教程的项目和学习路线。
延伸阅读