摘要:当两个独立错误恰好把结果拉回到“看起来差不多”时,测试通过不代表公式正确,尤其是在运动控制这类边界敏感场景里。
这篇文章复盘一个很适合拿来讲工程直觉的案例:一个步进运动时间公式里同时存在两处错误,单看任何一处都足以引发异常,但它们叠在一起以后,居然在大部分工况下给出了“看着还行”的结果。真正把问题暴露出来的,是一个短行程边界条件。
两个 Bug 为什么能互相抵消:一次运动时间公式的误导性正确
很多人对 bug 的直觉是:有错就会立刻错得很明显。
可工程里更危险的情况,恰恰是“错了,但没完全错”。结果不离谱,流程能跑,测试大部分时候也过,于是所有人都会下意识地相信那套公式本身没有问题。
这次踩到的就是这种坑。
事情是怎么开始的
某段步进动作在正常工况下表现一直还行,直到一个短行程场景反复在超时边界附近出现异常。现象不是完全失控,而是:
- 大多数动作都能跑完
- 只有少数短段动作容易误判超时
- 现场看起来像“时间预算有点偏”
这种表现最容易让人先去怀疑:
- 超时阈值配小了
- 电机加速度太激进
- 机械阻力波动导致实测时间偏大
结果顺着公式往回推,才发现根因比“参数不准”更严重:公式本身有两处独立错误。
那两个错误分别是什么
示意代码如下:
float time_to_accel(float start, float target, float accel, float pulses)
{
float v = sqrtf(start * start + accel * pulses);
float t = (v - start) / accel;
return t;
}
如果把它和理论推导对照,会看到两件事。
错误一:距离公式里少了一个系数
本来该进入根号的是:
start² + 2 * accel * pulses
结果实现里写成了:
start² + accel * pulses
这会让推导出的中间速度偏小,进一步把时间也算短。
错误二:只算了单程,没有算完整的加减速过程
对于对称的短行程运动,如果你的目标是估算完整动作时间,就不能只取单边时间。可实现里只返回了一次加速段的结果,没有把另一半过程计进去。
这又会把结果再压短一截。
最麻烦的地方:两个错误叠起来,结果居然没那么离谱
这才是这个案例真正有意思的地方。
两个错误都在把结果往“更短”拉,但它们作用的方式并不完全一样。某些参数区间里,误差不会简单地线性放大,反而会在数值上落到一个“勉强像对的”范围。
于是现场就出现了一种非常误导人的局面:
- 长行程看起来没什么问题
- 常规动作时间和经验值差得不算夸张
- 绝大多数测试都能通过
这会让人自然得出一个错误结论:公式大体没问题,只是边角参数需要再调。
实际上,公式从根上就是错的。
为什么短行程更容易把问题掀出来
短行程动作对时间估算更敏感,因为它没有足够长的稳定段去“稀释”前面公式的偏差。
一旦动作脉冲数很少,就会出现这些情况:
- 加速段占比变大
- 公式误差不再被长匀速段掩盖
- 超时阈值和实际动作时间更容易贴边
于是原本在长段动作里不显山不露水的错误,到了短段动作里就会被放大成状态机误判。
这个问题真正说明了什么
它说明“测试通过”和“模型正确”是两回事。
尤其是运动控制、滤波、控制参数换算这类问题,只要满足下面任一条件,就很容易出现“看起来对”的错:
- 测试样本主要集中在常规工况
- 误差方向相同,但量级恰好接近
- 系统上层有超时、容差或经验参数在兜底
当这些东西叠在一起时,错误不会第一时间以“完全失效”的方式暴露出来,而是以“偶发边界异常”的形式出现。
更稳妥的做法,不是多试几次,而是做反推校验
这类公式一旦代码化,我很建议加一轮 back-check,也就是反推验证。
例如给出一组明确输入:
start = 2000
target = 60000
accel = 90000
pulses = 3333
然后同时做三件事:
- 手算或脚本计算理论值
- 跑实现函数拿到输出
- 拿实际示波器或脉冲计数结果做对照
如果三者对不上,就别急着调阈值,先怀疑公式。
代码层面还能补哪些防线
除了修公式本身,我更建议补这几类工程防线:
- 给关键公式单独写单元测试,至少覆盖长行程、短行程和边界脉冲数
- 把三角波和梯形两种路径拆开,不要混在一个“看似通用”的实现里
- 在注释里直接写明物理意义,而不是只写变量名
- 对“超时预算”这类上层依赖值,记录推导来源
这些工作看起来没有“修 bug”那么立竿见影,但它们能大幅降低下一次再被“误导性正确”骗过去的概率。
最后一句
真正危险的错误,不是算出来离谱得一眼能看出来的那种,而是算出来“差不多对”的那种。
因为前者会逼你立刻修,后者会让它活很久,直到某个边界条件把所有侥幸都收走。
Comments NOTHING