本文的内容是对《计算机系统概论》第二版(梁阿磊等译)中第十五章“测试与调试技术”的一个小结。
一、概论
程序员通常花费更多的时间来调试程序,而不是编写程序。
测试的目的是“暴露”问题(bug),而测试的目的是“解决”问题。测试代码的基本方法,通常是向程序(或局部代码)注入尽可能多的、各种各样的输入条件,以迫使软件暴露bug。以ToUpper函数的测试为例(该函数将输入的字母改为大写并返回)。我们将传入所有可能的ASCII码,然后观察函数的输出结果看看函数是否按规范工作。我们应该尽可能在开发阶段就发现bug,而不要让毫无准备的用户在意外情况下遇到错误。
程序调试如果犯罪侦查,程序员必须检查所有可能的线索,才能找出问题的根源。调试的第一任务是收集bug信息,学会怎样“系统地”收集bug信息会对调试大有帮助(如代码中关键变量的执行结果)。
本文将介绍几种在程序中查找和修订bug的技术。首先,我们介绍编程中一些常见错误的分类;然后介绍快速发现这些错误的测试方法;最后,是隔离和修补这些错误的调试技术。同时,我们还将介绍一些“预防”技术,以尽量减小代码出错的可能性。
二、错误类型
代码的错误分类三类:
- 语法错误(Syntactic error) 最容易处理,因为编译器就能捕捉它们,类似语句缺少分号变量未声明这样的错误;
- 语义错误(Semantic error) 很难修改。语义错误出现的情况是语法上完全正确,但执行结果却与我们期望的不同,类似于数值溢出变量表达范围;
- 算法错误(Algorithm error) 指我们解决问题所采用的方法出错了,通常这类问题很难检测,且即使检测到了也很难修改,类似于大名鼎鼎的2000年问题(Y2K bug);
三、测试
在资深程序员中流传着一句话:“任何没有测试过的代码都存在着bug”。
什么是测试?即我们让软件在各种可能的输入模式(模拟真实使用环境)下运行,然后分别检查输出是否正确。理想情况下,程序测试方法就是检测程序在所有可能输入下的执行情况,但这几乎是不现实的,因为有些程序的输入组合非常庞大,测试时间高达几十年上百年。通常,软件工程师采用一些系统的方法来测试代码。典型的有,黑盒测试法(black-box),用来测试程序功能是否满足设计规格说明(specification);白盒测试法(white-box),则用来有目的地测试程序实现的各个方面,并保证每行代码都接受了测试。
3.1 黑盒测试
黑盒测试关注的是程序的输入和规格说明上的输出是否匹配,不关心其内部具体实现。换句话说,黑盒测试关心“程序能做什么”,而不关心“它是怎么做的”。
程序的黑盒测试过程包括:运行被测试程序,输入数据,将被测试程序输出与“检查程序器”(checker)的输出相比较,如此重复测试,直到确信被测试程序的功能是可靠的。其中,checker与被测试程序不同,但却能完成相同的计算任务。但是,如果checker与被测试程序存在相同的bug,则黑盒算法检测必然失败。也正是因为这个原因,编写checker的人是不允许看被测试程序的代码,以保证两者的独立性。
面对大型程序,需要设计一个“测试”程序来自动完成测试任务,该“测试”程序即称为“黑盒”。黑盒能自动运行被测试程序,并为其提供随机输入,同时匹配被测试程序和checker的输出结果,并重复这项工作。
3.2 白盒测试
然而,对于一个大型程序,黑盒测试是不够的。原因有二,其一,在黑盒测试中,无法知道哪些代码被测试,哪些未被测试;其二,黑盒测试的前提是整个软件已经开发完成,这意味着接受测试之前,该程序不仅能通过编译,并且必须已有一些规格说明。
软件工程师在使用黑盒测试的同时还辅之以白盒测试的方法。白盒测试的做法是,将软件内部的各种组件相互独立,然后分别测试各组件是否达到设计要求。例如,根据白盒测试来测试每个函数或循环体是否正确执行。
如何构建白盒测试呢?大多数测试中,我们可能需要修改源代码。例如,为了测试一个函数是否正确,我们需要添加一些代码,该代码将增加运行时间但能重复调用该函数,且每次调用时变换不同的输入和检查相应输出。我们可能在代码中添加大量printf语句,将内部变量打印出来以观察它的值并检查执行是否正确。当代码完成或即将发布的时候,再将这些printf语句删除。
常用的白盒测试技术是在程序中有策略的安放一些检错代码,例如断言器(assertion)。这些代码将检测能表示程序是否正确运行的条件。当不正常状态被捕捉后,检测代码将打印一条警告信息,并显示当前状态相关信息,或将程序永久性终止。
一个完整的测试中,黑盒法和白盒法都是必须的。
四、调试
发现错误之后的任务就是修复它。有效地进行调试的关键是收集相关标识错误的信息,以此诊断出错误。可用的方法有特定方法和基于调试工具的系统化技术。
4.1 特定方法
特定方法适用于简短或比较熟悉的程序。通过插入打印语句,查看重要的中间变量。
4.2 源码级调试工具
在我所使用的ubuntu16.04系统中,我常用gdb调试C++程序,ipdb调试python程序。
源码级调试工具用于一个程序上时,被调试的程序必须已编译,同时编译器在编译源码时会对可执行代码做一些扩充,即在执行代码中加入足够的辅助信息以便协助调试工具工作。此外,调试工具将需要从编译过程获得信息,以便将每个机器语言指令和对应的高级语言程序语句关联起来。调试工具还需要变量名及内存位置的相关信息,以便程序员可以用源代码中的变量名来查看各个变量的取值。
4.3 断点
着重提及“条件断点”和观察点(watch point)。
条件断点:当某条件为“真”时,暂停程序;
观察点:在某条件为“真”的地方设置一个观察点来暂停程序的执行。与断点的区别在于,观察点不与任何一条代码有关,而是作用到所有语句行。
五、正确的编程方法
5.1 明确规格说明
很多bug的起因是程序规格说明(specification)的定义差或不完备。例如,现有一个程序的设计说明“写一个程序,从键盘输入数字,求其阶乘”,这样的说明是不完备的,因为如果输入一个负数呢?或者输入零呢?或者输入一个很大的数字导致溢出呢?面对这些情况,所写出来的程序必然出错。为了改正这类错误,就需要修改程序规格说明,使得程序在遇到输入小于等于零的值,或阶乘结果 n!>231 这些情况也能正确执行。
5.2 模块化设计
函数是非常利于扩展编程语言的功能。通过函数,我们可以方便地为特定的编程任务添加新的操作和构件。通过这种方式,很自然地使得我们的程序具有模块化的模式。
一旦完成一个函数,我们就可以对它独立测试,判断它的工作是否如期完成。相对于完整的程序来说,一个典型的函数仅完成一个小任务。与整个程序相比,它更容易测试。
模块化设计概念在系统设计中很重要,即系统的构建可基于一些简单的、已预测试的、工作正常的组件。库函数是一组已预测试的组件的集合,现代编程严重依赖库函数。
5.3 预防错误式编程
- 注释代码:代码文档能够帮助工程师重新审核与反思代码的过程,发现疏漏的特例情况或操作条件的处理;
- 采用统一的编码格式:例如对其左右括号,统一变量名命名方法;
- 不作任何假设:例如,写函数时,总会习惯性假设参数落在一定范围内,此时容易埋下错误隐患。写代码要避免任何假设,或者至少通过断言和现场检查来指明哪些假设不成立;
- 避免全局变量:全局变量可能简化一些编程任务,但它们会使代码难以理解和扩展,而且一旦发现bug很难分析出错原因;
- 依赖编译器:如果使用gcc编译器,可以用"gcc-Wall"使编译器列出所有警告信息;