教程 · 入门 · 25 分钟
机器人碰撞检测:碰撞传感器与消抖
开关闭合一次产生的不是一个边沿,而是九个。本文讲抖动如何骗过中断、消抖窗口的代价,以及如何做一个经久耐用的碰撞保险杠。
开关不是干净的边沿
按下一个杠杆微动开关,金属触点会到达、偏转、分离、再到达——在最初几毫秒里反复好几次,然后才稳定下来。这就是触点抖动,它是机械问题:无论在它前面加多少软件、还是在它后面加多少电容,都没法让那块金属停止运动。
之所以要紧,只是因为读它的东西比金属快得多。一颗 16 MHz 采样的单片机,会把这些跳变每一次都当成真实的边沿。
下图就是设计曲线。边沿数随窗口增长呈阶梯式下降,在抖动结束的那个点降到一,之后保持不变。窗口短了会多数,窗口长了就是白白付出的延迟。
典型的抖动时长,值得亲自量一量而不是照抄:
| 开关 | 抖动 |
|---|---|
| 优质杠杆微动开关 | 0.5–2 ms |
| 便宜的轻触按键 | 2–10 ms |
| 干簧管 | 0.1–1 ms |
| 继电器触点 | 1–20 ms |
**常见的“5 ms 就够了”这个建议,只是对表格中间位置的一个猜测。**量一量你自己的:一行代码的事,它把一个迷信变成一个数字。
先接线,这些才有意义
碰撞开关要的是常开触点接地,引脚上拉:
pinMode(BUMP_L, INPUT_PULLUP); // idle HIGH, LOW when the bumper is pressed
选这种接法而不是另一种,有三个理由:
- **低电平有效,失效时更安全。**断线读到的是高电平,也就是没有按下,所以保险杠的线断了不会让机器人以为自己永远在碰撞。下拉接法的失效方向正相反。
- **内部上拉是免费的。**不需要外接电阻,也不用在一台前端会被反复撞击的机器人上多加零件。
- **噪声被旁路掉,而不是被收集。**一根长的保险杠线就是天线;触点闭合时那条到地的低阻抗路径,才让它不去拾取旁边电机驱动器的噪声。
改用开关的常闭触点,你会得到相反的失效行为——断线看起来像一次永久碰撞。对急停来说这是正确选择,对保险杠来说是错误选择。
消抖:三选一
**接受后忽略。**最省事的一种,对保险杠来说也是正确的做法。立刻接受第一个边沿,然后在窗口期内拒绝再看:
const unsigned long BOUNCE_MS = 6;
unsigned long lastBumpAt = 0;
bool bumped() {
if (digitalRead(BUMP_L) == HIGH) return false; // not pressed
if (millis() - lastBumpAt < BOUNCE_MS) return false; // still inside the window
lastBumpAt = millis();
return true;
}
对那个要紧的边沿零延迟,抖动在它背后被丢弃。你要的就是这种:当反应是停电机时——为求稳妥多等 6 ms,就是多往家具里扎 6 ms。
**等稳定。**Arduino 示例的做法:记住读数上次变化的时间,只有在它保持不变达到整个窗口之后才接受。它付出整个窗口的延迟,而对菜单上的按键来说这是合适的形状——那里误触比反应慢更糟。
**硬件。**触点并一只 100 nF 电容、加上上拉,在 100 kΩ 内部上拉下给出约 10 ms 的 RC——但它产生的边沿很慢,而慢边沿进入数字输入可能在阈值附近来回抖动。走这条路的话,后面要跟一个施密特触发输入。通常不值得为这个多几个零件。
注意这三种做法的共同点:它们都是非阻塞的。边沿之后一个 delay(20) 确实能把抖动消干净,代价是 20 ms 内车什么都读不到;对一台以 0.3 m/s 行进的机器来说,那是 6 mm 的盲走——见用毫秒计时做时序。
中断和抖动是一对坏搭档
给保险杠挂中断看上去理所当然,实际上通常不是。
九次跳变意味着 ISR 会触发九次。如果它递增一个计数器,你现在有九次碰撞;如果它置一个标志位,你有一次,无害。只有处理函数幂等时才用中断——置标志、记时间戳——如果它要计数,就永远别用。
volatile bool bumpFlag = false;
volatile unsigned long bumpAt = 0;
void onBump() { // idempotent: nine calls do what one call does
if (millis() - bumpAt < BOUNCE_MS) return;
bumpAt = millis();
bumpFlag = true;
}
在 AVR 上,millis() 在 ISR 内部不会前进,这让它对一次抖动的持续时间是安全的,但对更长的时间就不对了——如果窗口长度要求精确,就用 micros()。
老实说,保险杠很少真的需要中断。200 Hz 的循环在 5 ms 内就能注意到一次碰撞,而车在这段时间里只走了 1.5 mm。直接轮询它,把中断引脚留给那些确实无法轮询的编码器。
搭一个真正好用的保险杠
开关只是容易的那一半。挡在它前面的机构,决定了机器人能不能发现一条斜着撞上来的椅子腿。
- **一个活动外壳,两个开关。**单块前面板架在两个弹性支点上,两侧各有一个开关顶在后面。只有左开关触发,就说明障碍在左边,而这正是避障行为决定往哪边转所需要的信息。
- **把包角转过来。**平的前保险杠会漏掉任何以超过约 30° 撞上来的东西,而擦碰正是大多数碰撞。至少让外壳每一侧各包过 45°。
- **回位靠弹簧,不靠开关。**微动开关自带的弹簧是 50–100 g,推不回去一个外壳。用橡皮筋或轻压簧,开关只负责感知。
- **给它行程和一个硬限位。**在开关动作前留 3–5 mm 行程,并让一个硬限位去吃冲击,而不是让开关本体去吃。开关本体才是会坏的东西。
- **和某种非接触的传感器搭配。**保险杠是最后手段——它报告的是已经发生的碰撞。装在它前面的超声波或飞行时间传感器才躲掉了大多数碰撞;保险杠负责抓住黑色椅腿、玻璃,以及高度不合适的东西。
自己测抖动
// Count transitions in the 20 ms after the first edge. Run it, press the switch,
// read the number. That number is your debounce window, in your robot, with your wiring.
void loop() {
if (digitalRead(BUMP_L) == HIGH) return;
unsigned long start = micros();
int last = LOW, edges = 0;
while (micros() - start < 20000) {
int now = digitalRead(BUMP_L);
if (now != last) { edges++; last = now; lastEdgeAt = micros(); }
}
Serial.print(edges); Serial.print(" edges, settled after ");
Serial.print(lastEdgeAt - start); Serial.println(" us");
delay(500);
}
把窗口设成你测到的稳定时间,再加大约 50%。开关旧了要重测——触点只会变差,不会变好。
出问题时
| 现象 | 通常原因 |
|---|---|
| 撞一次被数成好几次 | 没有消抖,或窗口比抖动还短 |
| 碰撞完全漏掉 | 某处有阻塞调用;循环没在看 |
| 车随机倒车 | 电机驱动器旁边有一根长而无屏蔽的保险杠线 |
| 报告永久碰撞 | 常闭触点上的线断了,或者没有上拉 |
| 只检出正面撞击 | 保险杠外壳太平——把包角转过来 |
| 用了一周开关就不工作了 | 开关本体吃了冲击,而不是硬限位 |
| 能检出墙,检不出椅子腿 | 开关没问题;外壳有缝 |
至于保险杠在整台机器人里处于什么位置,房间覆盖机器人就建立在两个保险杠之上,别无他物——而覆盖模拟器会精确展示只靠一比特感知时机器人能走多远。
探索关联图谱
本教程所属的搭建
包含本教程的项目和学习路线。
延伸阅读