1.3 软件缺陷——几个真实的惨痛案例
上一节我们还在轻松地聊飞蛾和“Debug”的词源,这一节,画风得变一变了。
用软件去控制复杂的机电系统,在今天不仅是“常见”,简直是“无孔不入”。但不幸的事实是:软件工程毕竟还太年轻,而我们人类又太容易犯错。当这两者在某个时刻结合起来——软件没有按照设计意图运行时——结果往往不只是屏幕上弹个报错框,而是真金白银的损失,甚至是鲜活生命的消逝。
这就是我们常说的“Bug”带来的现实代价。
接下来的几个案例,每一个都值得用黑体字写下来。我这里的简短描述只是抛砖引玉——真的要理解这些灾难背后的复杂技术细节,你得去啃那些厚厚的官方事故调查报告(章末的“延伸阅读”里有链接)。我之所以在这里要把这些陈年旧账翻出来,不是为了吓唬你,而是为了强调两件事:
- 即便是在那些大规模、经过严格测试的系统中,软件故障依然可能发生,而且一旦发生就是毁灭性的。
- 对于我们这些身处软件生命周期任何一环的人来说,这是一记警钟:少一点自以为是,多一点严谨设计、仔细实现和彻底测试。
Patriot 导弹的悲剧:精度的代价
把时钟拨回 1991 年的海湾战争。美国在沙特阿拉伯的达兰部署了一组“爱国者”导弹防空系统。它的任务很明确:追踪、拦截并摧毁来袭的伊拉克“飞毛腿”导弹。
但在 1991 年 2 月 25 日,其中一套爱国者系统失手了。这枚失手的导弹直接击中了军营,导致 28 名士兵丧生,约 100 人受伤。
随后的调查报告把矛头指向了软件追踪系统的核心——一个关于时间计算的致命缺陷。
简单来说,系统记录的运行时间是一个单调递增的整数值。为了计算方便,软件需要把这个整数转换成实数(浮点数)。做法是将整数乘以 1/10(即 0.1)。
等一下,就在这里停一下。
如果你懂一点计算机原理,或者只是出于直觉,你可能会觉得 0.1 是个很简单的数。但在二进制世界里,0.1 是一个无限循环小数:
0.000110011001100110011001100110011...
爱国者导弹系统的计算机使用 24 位寄存器来存储这个转换结果。这意味着,超过 24 位的部分会被直接截断。这就是“精度丢失”的根源。
平时这不算什么大问题,但悲剧发生那天,该系统已经连续运行了大约 100 个小时。
这 100 个小时的累积误差,在经过那一次致命的 0.1 乘法转换后,放大成了大约 0.34 秒的时间偏差。
0.34 秒听起来微不足道,对吧?
但别忘了,飞毛腿导弹的速度大约是 1,676 米/秒。在这 0.34 秒里,飞毛腿已经飞出了大约 570 米。
对于一个雷达系统来说,570 米的误差意味着目标已经跑出了“距离波门”的追踪范围。雷达屏幕上看不到它,导弹自然也就拦截不到它。
这就是一个典型的整数转浮点数精度丢失引发的灾难。
Ariane 5 火箭的爆炸:复用的陷阱
如果你觉得 0.34 秒已经够荒谬了,那 1996 年 6 月 4 日的这个故事会告诉你,软件工程里还有一种更隐蔽的杀手——名为“复用”的陷阱。
那天清晨,欧洲航天局(ESA)的 Ariane 5 重型运载火箭在南美洲法属圭亚那的库鲁航天中心升空。仅仅 40 秒后,这枚造价昂贵的火箭就失去了控制,在空中炸成了一团巨大的火球。
最终的调查报告令人震惊:直接原因是一个软件溢出错误。
但这背后的故事远不止“溢出”两个字。让我们把这一连串像多米诺骨牌一样的崩溃过程拆解开来看:
- 溢出的发生:代码试图将一个 64 位的浮点数值转换成 16 位的有符号整数。
- 未做保护:这是一个没有任何保护措施的强制转换。当数值过大时,直接抛出了异常。
- 异常的来源:那个过大的数值是一个内部变量(BH,水平偏差)。对于 Ariane 5 来说,这个数值比设计预期的要高得多,因为它继承了 Ariane 4 的逻辑。
- 连锁反应:这个异常直接导致了惯性参考系统(SRI)的关闭。主计算机(OBC)收到了错误的数据,向喷管偏转器发出了完全错误的指令。
- 最终结果:助推器和主发动机的喷管完全偏转,火箭剧烈偏离航线,最终解体爆炸。
最讽刺的是什么?
出事的惯性参考系统(SRI),在发射后按理说是根本不需要工作的。但由于发射窗口略有延迟,设计上规定它要在发射后保持活跃 50 秒。这就给了那个致命 Bug 40 秒的作案时间。
后来的技术分析(比如 Jean-Marc Jézéquel 的分析报告)一针见血地指出:这是一个复用错误。
出问题的 SRI 水平偏差模块,是从 10 年前的 Ariane 4 软件中直接照搬过来的。设计者假设:既然 Ariane 4 上跑得好好的,那 Ariane 5 上也没问题。
假设。 这就是工程师最危险的词。
Mars Pathfinder 的重启:优先级的反转
让我们把目光投向火星。
1997 年 7 月 4 日,NASA 的“探路者”号着陆器成功登陆火星,随后释放了那辆大名鼎鼎的“旅居者”号漫游车——这是人类历史上第一辆在另一个星球上轮动的车辆。
任务一开始很顺利,但没过多久,地面控制中心发现着陆器开始出现周期性的重启。
这可是隔着几千万公里,没法手动按 reset 键。工程师们不得不从地球进行远程诊断。最终,他们确定这是一个教科书级别的并发问题:优先级反转。
这是怎么回事?
在实时操作系统里,我们通常会给任务分配优先级。高优先级的任务应该先执行。但是,如果高优先级任务正在等待一个被低优先级任务占用的资源(比如一把锁),那个高优先级任务就得等着。
这本身听起来还好——反正低优先级任务很快就会释放锁。
但在这里有一个“第三者”:中优先级任务。
中优先级任务并不依赖那把锁,但它优先级比低优先级任务高。于是,它抢占 CPU,导致低优先级任务(拿着锁的那个)迟迟得不到运行机会去释放锁。结果,高优先级任务就一直等着,饿死了。
在 Pathfinder 上,这个高优先级任务被饿得太久,久到触发了系统里的另一个机制——看门狗定时器。
看门狗定时器的逻辑很简单:在一定时间内没人来“喂狗”(重置定时器),我就认为系统挂了,强制重启。于是,系统一遍遍重启。
讽刺的是,解决这个问题的办法非常成熟。
VxWorks 这个实时操作系统本身就提供了“优先级继承”的功能。只要打开这个开关,持有锁的低优先级任务会自动“继承”等待它的高优先级任务的优先级,从而快速执行完临界区并释放锁,避免饿死。
但当时喷气推进实验室(JPL)的团队在配置 VxWorks 时,把这个选项关掉了。
好在,他们虽然犯了错,但也留了后路。JPL 团队在任务期间特意预留了一个调试数据流通道,持续向地球发送遥测数据。正是依靠这些详实的日志,他们才在地球上重现并定位了 Bug。
修复方案很简单:从地球发送指令,开启信号量的优先级继承功能。
系统重启消失了,任务继续。
JPL 团队负责人 Glenn Reeves 后来总结了一句很有分量的话:
"We test what we fly and we fly what we test." (我们测试什么就飞什么,我们飞什么就测试什么。)
这值得每一个嵌入式和系统软件开发者抄在笔记本扉页上。
Boeing 737 MAX 的坠落:单点故障
比起之前的案例,Boeing 737 MAX 的悲剧可能离我们更近,也更让人痛心。
2018 年 10 月 29 日,狮子航空航班从雅加达起飞,几分钟后坠入爪哇海。 2019 年 3 月 10 日,埃塞俄比亚航空航班从亚的斯亚贝巴起飞,几分钟后坠毁。
两起事故共夺走了 346 条生命。
这就是波音 737 MAX 的 MCAS(机动特性增强系统)灾难。事情的起因要追溯到 737 MAX 的硬件改动——为了更大的引擎,飞机的气动外形变了,容易在大迎角时失速。
工程师想出的“修复方案”是一个纯软件补丁:MCAS。当系统检测到迎角过大时,MCAS 会自动压低机头,以此来“纠正”飞机姿态。
这听起来很合理。但这里有一个极其致命的设计缺陷:MCAS 仅依赖单一传感器。
而且,这个软件逻辑被赋予了极大的权限——它可以无视飞行员的操作,强行下压机头。
当那唯一的传感器故障时,MCAS 就会误以为飞机即将失速,于是疯狂地压低机头。而飞行员,往往甚至不知道 MCAS 的存在,更不知道如何在一团混乱中迅速判断并关闭它。
一个软件系统,在设计时没有考虑到传感器的失效模式,也没有考虑到人的因素,最终导致了不可挽回的后果。
其他的警钟
除了这些上了头条的特大事故,软件世界里还有无数令人哭笑不得、或者细思极恐的 Bug:
- 海拔归零:2002 年 6 月,美国德拉姆堡。一份陆军报告指出,软件缺陷导致了士兵死亡。原因极其荒谬:如果操作员没有显式输入目标海拔,系统默认设为 0(海平面)。而德拉姆堡的海拔是 679 英尺。这个误差导致炮弹 calc 偏差,击中了自己人。
- 未加密的直播:2001 年 11 月,一位英国工程师 John Locker 惊讶地发现,他竟然能用普通的卫星电视接收器截获美军卫星的信号——那是美国间谍飞机在巴尔干上空的实时侦察画面。原因?数据流未加密。这在今天的 IoT 设备里依然常见。
- Linux 内核的坑:如果你觉得自己写的代码很烂,去搜一下“Linux kernel bug story”吧。你会看到即使是世界上最顶尖的黑客,也会在内核里留下看似愚蠢但后果严重的 Bug。
写到这里,希望你已经有了一种紧迫感。
这些不是故事,是教训。它们时刻提醒着我们:哪怕一行代码的错误,都可能被无限放大。
好了,沉重的话题到此为止。如果你现在已经迫不及待想要开始动手调试 Linux 了,那我们就别浪费时间——
让我们从搭建工作台开始。