本节书摘来自华章出版社《Effective Debugging:软件和系统调试的66个有效方法》一书中的第1章,第1.7节,作[希]迪欧米迪斯·斯宾奈里斯(Diomidis Spinellis),更多章节内容可以访问云栖社区“华章计算机”公众号查看
第7条:试着用多种工具构建软件,并将其放在不同的环境下执行
有时我们可以通过改变环境来锁定一些难以捕获的bug。例如,我们可以用另外一款编译器来构建这个软件,也可以切换到其他的运行时解释器、虚拟机、中间件、操作系统或CPU架构上。由于那些环境可能会更加严格地检查输入数据,或能通过其结构来凸现程序中的错误(参见第17条),因此可以帮助我们发现原来很难找到的一些bug。如果程序不够稳定、总是发生无法重现的崩溃问题,或移植起来不太顺利,那就应该试着把它放在另外一种环境下进行测试,这使得我们能够使用更为先进的调试工具,例如,一款图形界面很漂亮的调试器或dtrace等(参见第58条)。
在其他操作系统里面编译或运行软件,可以把我们对API的用法所做的错误假设暴露出来。例如,由于某些C及C++头文件声明了很多不一定会用到的实体,因此我们总是认为只要把这些头文件包含进来就够了,这可能导致我们忘记将另外一个必需的头文件包含进来,从而使客户遇到移植方面的问题。此外,某些API的实现方式在各种操作系统之间会有很大的区别,例如,Solaris、FreeBSD及GNU/Linux系统会采用不同的方式来实现C程序库,而桌面版与移动版的Windows API,目前也是基于不同的代码库来实现的。请注意,那些在底层采用C程序库和API的解释型语言也会受到影响,如JavaScript、Lua、Perl、Python或Ruby等。
对于C和C++这样较为接近硬件的语言来说,底层的处理器架构会对程序的行为造成影响。过去几十年间,Intel x86架构与ARM架构分别成为桌面市场与移动市场的主流,于是,那些在字节序上面(SPARC、PowerPC)或是在对空指针的解引用上面(VAX)偏离主流的架构,就变得不那么流行了。然而,x86架构与ARM架构在处理未对齐的内存访问及内存布局时依然有所区别。例如,如果在奇数内存地址处访问两字节的值,那么就有可能令某些ARM架构的CPU出错,或令CPU表现出非原子的(non-atomic)行为。在其他架构上面进行未对齐的内存访问,可能会严重影响程序的性能。此外,结构体的大小,以及其中各成员距离结构体开始处的偏移量,在这两种架构中也是有区别的,对于老版本的编译器来说,这种区别尤其突出。还有一个更为重要的问题在于:当你把代码从32位架构移植到64位架构,或是从一个操作系统移植到另一个操作系统的时候,长整数(long)及指针值等原始类型所占据的大小可能也会有所改变。下面这个程序可以显示出五种原始类型所占据的字节数。
在较为典型的几种环境之下,上述程序所给出的结果分别是:
由此可见,把软件放在其他架构或操作系统中运行,可以帮助我们对其进行调试,并检测出移植方面的问题。
对于移动平台来说,各种设备之间的差距要比桌面平台更大,它们不仅在操作系统的版本方面有所不同(大多数手机与平板厂商都会对原始的Android系统进行修改,并把改版后的系统安装在设备上),而且在硬件方面也有着相当大的区别,如屏幕分辨率、操作界面、内存及处理器等。这使得我们在开发移动软件时,更有必要将其放在各种不同的设备上进行调试,为此,很多移动app的开发团队都有许多种移动设备。
在其他执行环境中调试代码,主要有三种方式:
1.?在工作站安装虚拟机软件,并且用虚拟机来运行各种不同的操作系统。这种办法还有一个好处,就是可以保留各种执行环境的原始镜像,我们对虚拟机进行配置时,是在这个原始镜像的基础之上进行修改的,如果有必要,我们可以把虚拟机恢复到原始状态。
2.?使用小型的廉价计算机。如果你主要面对的是x86架构,但同时又想尝试ARM CPU,那么最简单的办法就是使用Raspberry Pi(树莓派)。这是一种基于ARM的微型设备,能够运行很多种流行的操作系统。它可以连接网线,或通过Wi-Fi上网。我们可以在这种设备上尝试GNU/Linux的开发环境,这对于主要在Windows或OS X系统上调试代码的人来说是很有帮助的。此外,如果你平常使用的是Windows系统,那么可以买一台Mac mini,这样就能够轻松地切换到OS X开发环境了。
3.?租用基于云端的主机,并在上面运行你想使用的操作系统。
要想用各种不同的编译器与运行时环境来调试代码,我们固然可以安装新的操作系统或使用新的设备,但除此之外还有一种办法,那就是设法使自己这台工作计算机上面的开发环境变得更加丰富。这样,我们就可以看到由其他开发工具所给出的错误与警告信息,并且可以用那些工具对代码中的某些方面进行更为严格的检查,看看它们有没有遵从相关的规范。与使用静态分析工具(参见第51条)所带来的好处类似,同时使用多种编译器,可以使我们发现更多的问题,这其中既包括移植方面的问题,也包括逻辑方面的问题。例如,如果某一款编译器对代码检查得比较宽松,而另外一款编译器检查得比较严格,那么就可以揭示出移植方面的问题;如果某一款编译器不对某个逻辑问题发出警告,而另外一款编译器对此发出了警告,那么就可以揭示出逻辑方面的问题。只要是符合语法的代码,编译器基本上都可以把它编译成可执行的文件,但是它们有时不太能够检查出对编程语言的误用,例如,即便代码所引入的头文件里面声明了一些没有公开发布的元素,有些编译器也依然会直接编译通过,而不会指出相关的问题,为此,我们应该多用几种编译器进行编译,以便把这方面的问题暴露出来。为了使开发环境变得更加丰富,我们在调试软件的过程中,不仅要使用主流的工具,而且还要同时安装并使用一些替代产品。下面给出几条建议:
开发.NET Framework程序的时候,不仅要使用Microsoft的工具与环境,而且还要同时使用Mono。
用Ada、C、C++、Objective C或其他相关的语言来编写程序时,要同时使用LLVM与GCC这两种编译器。
开发Java程序时,要同时使用OpenJDK(或由Oracle公司基于相同的代码库所提供的JDK)及GNU Classpath这两种开发包。此外也要注意把程序放在一种以上的Java运行时环境里面执行。
开发Ruby程序时,不仅要使用作为参考实现的CRuby来进行开发,而且还要尝试其他VM,如JRuby、Rubinius及mruby。
还有一种更为大胆的做法,那就是把程序里面的一部分代码改用另外一种语言来重新实现。如果你要调试的是个比较麻烦的算法,那么这种做法就很有用处。最为典型的情况是:你起初用了一种较为低级的语言来实现这个算法,然后发现这种实现方式无法运作,于是,你考虑采用Python、R、Ruby、Haskell或Unix shell等更为高级的语言来重新实现它。在实现过程中,你应该使用这些语言所提供的高级特性,例如,可以对集合、管道与过滤器进行操作,并使用高阶函数(higher-order function)等,这样能够帮你实现出一个可以正常运作的算法。当你迅速查明算法的设计问题并将实现中的错误加以修复之后,如果觉得这种新的实现方式在性能上无法满足要求,那么可以回过头去,用原来的语言或某种较为接近CPU的语言,把算法再重新实现一遍,并采用各种对比式的技术进行调试(参见第5条),使其能够正常运作。
要点
用多种编译工具来构建软件,并将其放在各种平台中执行,可以给调试工作提供很多有价值的思路。
如果遇到了一个很难调试的算法,那么可以考虑改用高级语言将其重新实现一遍。