各种奇葩的BUG

每逢周三就崩溃

转载地址:http://blog.jobbole.com/95634/
拿点儿喝的坐好,是时候讲讲我最喜欢的 bug 的故事了。

那是我第一份 IT 相关的工作:在一个生产重要医疗设备的厂商担任软件开发的暑期实习生。那些设备主要是麻醉给药系统和病患监控设备,后者就是在卧床患者旁边放着的发出“哔哔”声的那种盒子,上面会以图形方式显示患者的脉搏、血压、呼吸等等。如果心电图变成一条直线的话还会立刻召唤护士。当时的办公室里全是 2 米高的装着笑气的罐子,还有长着超级大胡子的嵌入式系统大拿,整屋子的人都在给各种设备准备文档,为了让它们通过 FDA 的认证。时不时还有人小声提到 10 年前没能在测试中发现的一个 bug,它导致了一台麻醉机在手术过程中间重启了。不用说,对于像我这种十几岁的新手,所有的生产系统肯定是不会让我们碰的。

(伯乐在线补注:一氧化二氮(Nitrous Oxide),又称笑气,无色有甜味气体,是一种氧化剂,化学式N₂O,在一定条件下能支持燃烧(同氧气,因为笑气在高温下能分解成氮气和氧气),但在室温下稳定,有轻微麻醉作用,并能致人发笑。)

不过他们还是给我安排了一份让人羡慕的工作,去测试一个在 1997 年听起来还十分时髦的原型项目:一个用 C++ 编写的服务器,它会监听患者监控设备的串口,然后把一些需要关注的事件转存到 SQL Server 数据库中,之后通过 CORBA 把数据发送到 Java Applet,于是医生或者相关人员就能通过互联网看到这个患者的状态了,它既能看到实时的数据,也能浏览之间的数据记录。帅气!只是那个时候我对这些语言和系统都一无所知!

接下来的几个星期就像杀猪一样的折腾,主要时间都花在了读懂让人头疼的 Visibroker ORB 手册,还有超级普通的类型转换 bug,不过我终于让我的“辛普森”系统磕磕绊绊地跑起来了,它用“Homer”(注:辛普森一家里的老爸)来记录和提供数据,然后用“Bart”(注:辛普森一家里的熊孩子)来进行显示。这几个星期让我觉得 CORBA 复杂得让人想死、AWT 让人头疼欲裂(比如 GridBagLayouts,呕)、applet 慢得像只蜗牛,不过 Java 看起来倒还像是个挺不错的语言。不过还有个小麻烦:C++ 服务器时不时就会突然崩溃掉,然后我开始尝试去搞明白到底是为什么。

因为我监听的那台监控设备在另一间屋子里,所以我绝大部分的开发和测试都是通过手动的“演示”模式来完成的,比如在一个循环里模拟一次心脏停跳之类的,据我所知,我的服务器从来没在这个过程中宕机过。不过在我或者别人手动摆弄那些控制器的时候,它确实崩溃过,尤其是在实际机器上操作的时候,不过我想尽办法也没能找到一个方法能让它稳定重现,甭管怎么做都不行。我把所有事件日志都记录到磁盘上,想找到在崩溃之前到底发生了什么,不过我小心翼翼地按照事件序列精确地手动重复了每一次事件(比如:把过滤器设置为 X,把控制器旋钮向右拧三个刻度,点击按钮……),我在两间屋子里跑来跑去(因为我在摆弄患者监控设备的时候是看不见我电脑上的日志的),但始终都没能让崩溃重现。不管是什么“鬼事件”(对我就是这么叫它的),它肯定是在造成崩溃的同时还逃过了所有日志。是不是有什么串口 I/O 或者硬件问题中断了事件?难道是宇宙射线把我 PC 上的数据位给改变了?
这里写图片描述
我把整天整天的时间都用来尝试去重现这个错误,但是毫无结果,在经历了几个星期的挫折之后,我最后干脆在所有从串口收到事件和写入数据库的操作中间都加了 printf 语句,在这个过程中,我重新检查了每一行代码,然后终于逐渐见到了曙光。

当我创建数据库结构的时候,为了节省空间而犯了一个错误,一个新手常犯的错误:把时间戳当成主键了。所以如果两个事件在一个毫秒内发生的话,数据库就会抛出主键唯一性约束的异常(译注:SQL Server 的 datetime 类型的精度其实不是1毫秒,而是3.33毫秒)。我之前注意到这个问题了,不过我觉得这种情况非常罕见,而且只会在没那么重要的环境中发生(比如在鼓捣监控设备内部配置的时候),所以我只是加了个 catch 语句,在日志中写了一条警告信息,然后继续执行后面的操作。

但是!这是个老派的代码,记录日志使用 C 语言风格的代码编写的,把日志字符串记录到了一个长度为 80 个字符的缓冲区中。唯一性异常这个消息本身是个常量,而日志的时间戳是格式化的,也就是实用了完整的英文的星期拼写(%E),所以输出就类似于“Monday, July 17, 1997, 10:38:47.123”。最后就是因为英文里面星期几的拼写有个有意思的属性:

星期几 单词长度
Sunday 6
Monday 6
Friday 6
Tuesday 7
Thursday 8
Saturday 8
Wednesday 9

明白了吧?星期三(Wednesday),而且只在星期三的时候,如果有人在监控器配置那儿手动进行了一个特定操作的话,就会在同一毫秒内产生两个事件,于是导致数据库抛出异常,而这个异常的消息包括字符串结尾的终结符的话,则刚刚好 81 个字符,导致了 80 个字符的缓冲区溢出,把程序搞挂了!

在那之后,在所有需要使用的数据库表中,我都会确保去用一个专门的、自增的整数 ID 作为主键,然后用 ISO 格式(也就是 YYYY-MM-DD)而不是星期几来记录所有日志。这些年来,我学到了不管一个 bug 看上去多么随机和不可预测,如果你挖得足够深的话,总是能找到一个符合逻辑的解释,极少有真的“不相关”的错误,几乎都是你特么自己的错。

不过 Dave Baggett 的编程生涯中,他却有过一次因为量子力学调试的经历。

一次因量子力学而 Debug 的痛苦经历

转载地址:http://blog.jobbole.com/50995/
回想起这个bug,仍然让我有些痛苦。作为一个程序员,在发现bug时,你学会了首先在自己代码中找问题,或许在测试一万次之后,你会把问题归咎于编译器。只有在这所有的都不起作用之后,你才会把问题归咎于硬件。

这是我遭遇一个硬件bug的故事。

抛开别的不说,我曾为《Crash Bandicoot》写存储卡(读写)代码。对于一个自大的游戏程序员,这就像是在公园里散步一样轻松愉快,我认为只要几天就写完了,最终调试用了六个礼拜。在此期间我做一些其他的事情,但我一直回来处理这个bug——几天内每天几个小时。这个bug实在烦人。

这个bug的症状是,当你需要保存你的进度时,代码会访问存储卡,而大部分情况下没有什么问题…但是偶尔读写会超时…没有任何明显的原因。一个短小的写入经常毁掉存储卡。玩家要保存进度,我们不仅不保存,还擦除他们存储卡上的全部东西。天哪。

过了一段时间,我们在Sony的制作人Connie Booth慌了。我们显然不能带着这个bug发布游戏,而六个星期之后我对于问题出在哪一点线索都没有。通过Connie我们向其他 PS1 开发者求助:有没有人出现过像我们这样的情况?没有。绝对没有任何人在存储卡系统上出现任何问题。

在你绞尽脑汁之后,你能做的唯一一个调试方法就是分而治之:一点点去除程序中的代码,直到留下的代码很少但你仍然出问题。像木雕一样去除没有问题的代码,留下的就是你的bug所在。

在这样的背景下挑战在于,视频游戏是很难去除某一部分的。在你删除模拟重力或者显示字符的代码后,如何运行游戏?

你必须做的是用一个假装做真正的事情,但实际上只是做很简单的不会出现bug事情的东西来替换掉整个模块。你必须写新的支撑代码来让这些玩意正常工作。这是一个缓慢而痛苦的过程。

长话短说:我做完了。我移除了大片大片的代码,相当多,只留下了初始化代码——就是准备游戏运行系统,初始化底层硬件等等。当然,我不能显示加载/保存菜单,因为我截除了所有的图像代码。但是我能够假装用户使用(不可见的)加载/保存屏幕并且请求保存,然后写入卡中。

我最终以一个带有这个bug的很少量的代码结束——但问题仍然随机出现!在大多数情况下没啥问题,但是偶尔会失效。基本上所有的Crash的实际代码都被移除了,但还是这样。这实在是莫名其妙:留下来的代码基本上都没做什么事。

在那时——估计是凌晨3点——一个想法蹦了出来。读写(I/O)涉及精确定时。无论是硬盘、存储卡、蓝牙发送器——随便啥——做读写的底层代码都是根据时钟来的。

时钟让不直接连接到CPU的硬件设备和cpu运行的代码同步。时钟决定了波特率——数据从一头传到另一头的速率。如果计时有什么问题,硬件或者软件或者两者都会乱七八糟的。这真的,真的很糟糕,并且通常导致数据损坏。

如果我们的初始化代码以某种方式弄乱了计时会怎么样?我又看了一遍测试程序中和计时有关的代码,并注意到我们将PS1上的可编程计时器设置到了1kHz(1000跳每秒)。这是比较快了,当PS1启动的时候,默认状态大概是100Hz。因此,大多数游戏将他们的计时器设置为100Hz。

这个游戏的带头(和除我外的唯一)开发者Andy,将计时器设置为1kHz,使得Crash的动作计算更加准确。Andy喜欢矫枉过正,如果我们要模拟重力,我们应该尽可能的提高精度!

然而如果提高计时器频率莫名其妙的干扰了整个程序的计时,故而将这个计时器设置到存储卡的波特率上会怎样呢?

我将计时器代码注释掉。然后我就无法复原这个bug了。但是这并不表示bug被修复了,这个问题是随机发生的。万一我只是运气好呢?

几天过去了,我还是在玩我的测试程序。Bug没有再出现。我回到全部的Crash代码中,修改了加载/保存代码,在访问存储卡之前将可编程计时器重置为默认设置(100Hz),之后设置回1kHz。从此之后没有发现问题再次出现。

但是…为什么?

我重新回到测试程序上,试着检测当计时器设置为1kHz时出现的那些错误的模式。终于,我注意到这些错误出现在使用PS1手柄的人身上。因为我自己很少这样做,所以我没有注意到(为啥我要在测试加载/保存代码的时候用手柄)。但是有一天我们的美工等我去完成测试(我确定那时候我在爆粗口),而他紧张的摆弄着手柄。卡损坏了。“等下,怎么回事?喂,再来一次!”

一旦我发现了这两件事是联系着的,就很容易重现bug:开始写入存储卡,动一下手柄,存储卡损坏。在我看来完全是硬件bug。

我去找Connie告诉他我的发现。她转述给设计过PS1的硬件工程师。她被告知:“不可能,这不可能是硬件问题。”我跟她说问一下我能不能直接和他说。

那个工程师给我打电话了,他用着他的烂英语,我用着我更烂的日语,我们争论一会。我最后说:“我给你一个30行的测试程序,让你在动手柄的时候能够出现这问题。”他答应了。他向我保证,这是浪费时间,而他正在一个新项目上很忙,但因为我们是Sony很重要的开发者,他会试的。

第二天晚上(我们在洛杉矶,而他在东京,所以对于我来说是晚上而他是到了第二天),他给我打电话,不好意思的向我道歉。这是个硬件问题。

我还是没有完全搞清楚问题到底在哪,但是我的印象中,从Sony总部的反馈听到的是,如果将可编程计时器设置到足够高的时钟频率,会影响到主板上时钟晶振附近的一些东西。这些东西之一就是存储卡的波特率控制器,同时也设置手柄的波特率。我不是搞硬件的,所以对于细节我相当模糊。

但是主旨是主板上两个独立部分的串扰,以及手柄接口和存储卡接口数据发送的结合在 1kHz 的时钟频率下会导致丢位,从而数据丢失,以致卡损坏。

这是我全部编程生涯中,唯一一次因为量子力学而debug的问题。

我遇过最难调的 Bug,最终发现是 CPU 的问题

转载地址:http://blog.jobbole.com/68840/
每个程序员都有些不畏死亡决战猛兽的英雄事迹。以下这些是我的。

内存冲突

毕业不到半年,拿着刚到手的文凭,我在Lexmark公司的一个嵌入式Linux固件开发团队中负责追踪一个内存冲突的问题。因为内存冲突的原因和问题表象总是相差非常大,所以这类问题很难调。有可能是因为缓存溢出,也有可能是指针未初始化,或是指针被多次free,亦或是某处的DMA错误,但是你所见的却是一堆神秘的问题:挂起、指令未定义、打印错误,以及未处理的内核错误。这些都非常频繁,内存冲突看上去似乎是随机出现又很难重现。

要调试这种问题,第一步是可重现问题。在我们奇迹般地找到这样一个场景之后,故事开始变得好玩起来。

当时,我们发现在运行时因内存冲突而产生的程序崩溃每几百小时就会出现一次。之后有一天有人发现一个特别的打印任务会产生内存冲突从而在几分钟之内就使程序崩溃。我从来不知道为什么这个打印任务会产生这个问题。现在,我们就可以进一步做些什么了。

调试

这个问题可重现之后,我就开始寻找崩溃中出现的模式。最引人注意的是未定义指令和内核错误,它们差不多三分之一的时间就会发生一次。未定义指令的地址是一个合理的内核代码地址,但是CPU读到的这个指令却不是我们期望出现的。这就很简单了,可能是有人不小心写了这些指令。把这些未定义指令的句柄打印出来之后,我可以看到这些错误的指令所在位置的周边内存的状态。

在做了大量失败的将更多的代码排除出崩溃的尝试之后,一个特殊的崩溃渐渐显现。

崩溃之王

这个崩溃解开了所有秘密。当时我们用了一个双核CPU。在这个特殊的崩溃里,首先CPU1在有效的模块地址范围内收到了一个未处理的内核错误,而此时它正在尝试执行模块代码,这段代码可能是一个冲突的页表或是一个无效TLB。而正在处理这个错误时,CPU0在内核地址空间内收到了一个非法的指令陷阱。

以下是从修改后的未定义指令句柄中打印出来的数据(已转为物理地址,括号中是出错地址)

undefined instruction: pc=0018abc4
0018aba0: e7d031a2 e1b03003 1a00000e e2822008
0018abb0: e1520001 3afffff9 e1a00001 e1a0f00e
0018abc0: 0bd841e6 (ceb3401c) 00000004 00000001
0018abd0: 0d066010 5439541b 49fa30e7 c0049ab8
0018abe0: e2822001 eafffff1 e2630000 e0033000
0018abf0: e16f3f13 e263301f e0820003 e1510000

以下是内存域应该显示的数据:

0018aba0: e7d031a2 e1b03003 1a00000e e2822008
0018abb0: e1520001 3afffff9 e1a00001 e1a0f00e
0018abc0: e3310000 (0afffffb) e212c007 0afffff3
0018abd0: e7d031a2 e1b03c33 1a000002 e3822007
0018abe0: e2822001 eafffff1 e2630000 e0033000
0018abf0: e16f3f13 e263301f e0820003 e1510000

确切地来说,只有一行缓存(中间那32byte)是有冲突的。一个同事指出冲突行中0x49fa30e7这个字是一个魔术cookie,它标记了系统中一个特殊环形缓冲区的入口。入口值的最后一个字永远是一个时间戳,所以0x5439541b是上一个入口的时间戳。我决定去读取这个环形缓冲的内容,但它现在挂在一个不可执行的KGDB提示那了。机器现在跟死了一样。

冷启动攻击

为获取环形缓冲区的数据,我进行了一次冷启动攻击。我为正在使用的主板搞到了一份概要拷贝,然后发现CPU的复位线上连了一块不受欢迎的板子。我把它短路了,重置CPU而不妨碍DRAM的完整性。然后,我把Boot挂载在引导程序上。

在引导程序里,我dump到了问题中环形缓冲区的内容。谢天谢地,这个缓存总是在一个固定的物理地址上被定位到,所以找到它不是问题了。

通过分析错误时间戳周边的环形缓冲区,我们发现了两个老的cache line。这两个cache line里有有效数据,但是在这两个cache line里的时间戳却是环形缓冲区里之前的时间。

导致CPU0上未定义指令的cache line与环形缓冲区里那两个老cache line之一相当契合,但是这并不说明其他可能的地方也是这样。我发现一个决定性的证据。假设,另一个消失的cache line是导致CPU1上未处理内核错误的元凶。

错置的cache line

cache line应该被写入0x0ebd2bc0(环形缓冲区里的cache line),但是事实上却写入了0x0018abc0(冲突的内核码)。这些地址在我们CPU上属于相同的缓存,它们的位[14:5]的值是相同的。不知为何它们有别名。

                    bit   28   24   20   16   12    8    4    0
                           |    |    |    |    |    |    |    |
0x0ebd2bc0 in binary is 0000 1110 1011 1101 0010 1011 1100 0000
0x0018abc0 in binary is 0000 0000 0001 1000 1010 1011 1100 0000

一个地址的低5位是cache line(32字节cache line)里的索引。后10位,即位[14:5],表示缓存集。剩下的17位,即位[31:15],用来表示缓存里当前存的是哪个cache line.

我向我们的CPU供应商提交了一个bug报告,之后他们制定了一个解决方案,并在下一版本CPU里修复了这个bug。

我期望听到更多牛掰的此类故事,也期望我自己可以再攒点这样的。

猜你喜欢

转载自blog.csdn.net/awp0011/article/details/50214287
今日推荐