对新平台上应用程序的开发者来说,64位平台的稳定和可靠,是吸引他们的关键;而任何内存错误问题都会导致开发工作的失败,内存错误最棘手之处在于它是难以捉摸的,找出它们非常困难且要花费大量时间。内存错误不会在通常意义上的测试中暴露出来,正是因为它们潜在的有害性,所以在程序定型之前,去除所有的内存问题就显得非
常必要了。
目前有一些强大的内存错误检测工具,它们可以在运行于双核心处理器的应用程序中,找出导致线程内存错误的原因;它可在传统测试技术找不出问题的地方,找出并修正那些难以捉摸、导致程序崩溃的"元凶"。错误检测工具可帮助你在发布程序之前,找出并修正那些C/C++内存错误,而在移植程序之前修正这些问题,可提高在新平台新架构上的程序质量,使移植过程更加流水线化,并且使老程序更加健壮可靠。
为何移植如此之难?
在向64位处理器或新硬件移植代码时产生的问题当中,大多数开发者是负有主要责任的。就此来说,代码在移植到新平台或新架构之上时,内存问题似乎也成倍增长了。
在过渡到64位架构时最基本的问题,就是对各种不同的int和指针在比特位长度上假定。在从long转换到int时,不管是赋值还是显式转换,都存在着一定的隐含限制。前者可能产生一个编译器警告,而后者可能被无声地接受,就此导致了运行时的各种错误。另一个问题就是int常量并不总是与int同样大小,这是混淆有符号和无符号常量的问题,同时,适当地使用有关的后缀可以减少此类问题的发生。
另一些问题的主要原因是各种指针类型的不匹配。举例来说,在多数64位架构上,指针类型不能再放入一个int中,而那些把指针值储存在int变量中的代码,此时当然就会出错了。
这些问题通常会在移植过程中暴露出来,因为移植从本质上来说是一种变体测试。当你在移植代码时,实际上是在创建一种"同等变体"(对原始代码的小幅改动,不会影响到测试的结果),而通过这些"同等变体",可找出许多不常见的错误。在C/C++中,创建和运行"同等变体",可揭示出以下问题:
1、缺少拷贝构造函数或错误的拷贝构造函数
2、缺少或不正确的构造函数
3、初始化代码的错误顺序
4、指针操作的问题
5、依赖未定义的行为,如求值的顺序
在准备移植应用程序时,有以下几个相关步骤
第1步、在移植之前,要保证原始代码中没有诸如内存崩溃、内存泄露等问题,找出指针类型和int错误的最有效的一个方法是,采用平衡变体测试,来达到运行时错误检测的目的。
变体测试最先是为解决无法计量测试的准确性问题而产生的,大致如下:假定已有了一个完美的测试方案,它已经覆盖了所有的可能性,再假定已有一个完美的程序通过了这个测试,接下来修改代码(称之为变异),在测试方案中运行这个"变异"后的程序(称之为变体),将会有两个可能的情况:
一是程序会受代码改变的影响,并且测试方案检测到了,在此假定测试方案是完美的,这意味着它可以检测一切改变。此时变体被称作"已死的变体"。
二是程序没受改变的影响,而测试方案也没有检测到这个变体。此时变体称作"同等变体"。
如果拿"已死变体"和已生成的变体作对比,就会发现这个比率要小于1,这个数字表示程序对代码改变有多敏感。事实上,完美的测试方案和完美的程序都不存在,这就说上面的两种情况可能会有一个发生。
程序受影响的结果因个体而异,如果测试方案不适当,将无法检测到。"已经变体"和"生成变体"的比率小于1同时也揭示了测试方案有多精确。
在实践中,往往无法区分测试方案不精确与同等变体之间的关系。由于缺乏其他的可能性,在此我们只好把"已死变体"对所有变体的比率,看成是测试方案的精确程度。
例1(test1.c)证实了以上的说法(此处所有的代码均在Linux下编译),test1.c用以下命令编译:
cc -o test1 test1.c.
main(argc, argv) /* line 1 */ int argc; /* line 2 */ char *argv[]; /* line 3 */ { /* line 4 */ int c=0; /* line 5 */ /* line 6 */ if(atoi(argv[1]) < 3){ /* line 7 */ printf("Got less than 3\n"); /* line 8 */ if(atoi(argv[2]) > 5) /* line 9 */ c = 2; /* line 10 */ } /* line 11 */ else /* line 12 */ printf("Got more than 3\n"); /* line 13 */ exit(0); /* line 14 */ } /* line 15 */ |
例1:程序test1.c 这个简单的程序读取输入的参数,并打印出相关的信息。现在假定用一个测试方案来测试此程序:
Test Case 1: input 2 4 output Got less than 3 Test Case 2: input 4 4 output Got more than 3 Test Case 3: input 4 6 output Got more than 3 Test Case 4: input 2 6 output Got less than 3 Test Case 5: input 4 output Got more than 3 |
这个测试方案在业界是有一定代表性的,它进行正则测试,表示它将测试对所有正确的输入,程序是否有正确的输出,而忽视非法的输入。程序test1完全通过测试,但它也许隐藏着严重的错误。
现在,对程序进行"变体",用以下简单的改变: Mutant 1: change line 9 to the form if(atoi(argv[2]) <= 5) Mutant 2: change line 7 to the form if(atoi(argv[1]) >= 3) Mutant 3: change line 5 to the form int c=3; |
如果在测试方案中运行此修改后的程序,Mutants 1和3完全通过测试,而Mutant 2则无法通过。 Mutants 1和3没有改变程序的输出,所以是同等变体,而测试方案没有检测
到它们。Mutant 2不是同等变体,故Test Cases 1-4将会检测到程序的错误输出,而Test Case 5在不同的电脑上可能会有不同的表现。以上表明,程序的错误输出,可看作是程序可能会崩溃的一个信号。
我们统计一下,共创建了三个变体,而只被发现了一个,这说明表示测试方案的质量为1/3,正如你看到的,1/3有点低,之所以低是因为产生了两个同等变体。这个数字应当作是测试不足的一个警告,实际上,测试方案应检测到程序中的
两个严重错误。
再回到Mutant 2,在Test Case 5中运行它,如果程序崩溃了,那这个变体测试不但计量到了测试方案的质量,还检测到了严重的错误,这就是变体测试发现错误的方法。
main(argc, argv) /* line 1 */ int argc; /* line 2 */ char *argv[]; /* line 3 */ { /* line 4 */ int c=0; /* line 5 */ int a, b; /* line 6 */ /* line 7 */ a = atoi(argv[1]); /* line 8 */ b = atoi(argv[2]); /* line 9 */ if(a < 3){ /* line 10 */ printf("Got less than 3\n"); /* line 12 */ if(b > 5) /* line 13 */ c = 2; /* line 14 */ } /* line 15 */ else /* line 16 */ printf("Got more than 3\n"); /* line 17 */ exit(0); /* line 18 */ } /* line 19 */ |
例2:同等变体
在例2中的同等变体(Mutant 4),它和前一个变体的不同之处在于,Mutant 4是同等变体,这意味着它在构建时的目的,就是要使修改后的程序如同原始程序一样运行。如果在测试方案中运行Mutant 4,那么Test Case 5大概会失败--程序将崩溃。此处表明,通过创建一个同行变体,实际上是增强了测试方案的检测力度,由此得出的结论是,有以下两种方法,可提高测试方案的精确性:
·在测试方案中增加测试数量
·在测试方案中运行同等变体
这两点是非常重要的,尤其是第二点,因为它证明了变体可提高测试的有效性。在这些例子中,是由手工创建了每一个变体,并且对每一个程序都作了单独的修改,这个步骤费时又费力,但是自动生成同等变体是有可能的,正如例3所演示的,这个程序没有输入,只有一个输出,原则上来说,它只需要一次测试: int doublew(x) int x; { return x*2; }
int triple( y) int y; { return y*3; }
main() { int i = 2; printf("Got %d \n", doublew(i++)+ triple(i++)); } |
例3:自动生成变体 Test Case 1: input none output 12 |
有意思的是,这个程序因编译器的差异,而分别给出答案13或12(注:译者在Visual C++ 2005中,得出的结果是10)。假设你要编写一个这样的程序,还要能在两个不同的平台上运行,如果不同平台上的编译器有所差异,此时你会察觉到这个程序的不同,疑问由此而生:"是哪错了?"这有可能就是导致问题产生的原因。 试想你在例4中创建了一个同等变体,此时这个程序的结果不依赖于编译器,实际上应是13,这也是在预料之中的。但一旦运行变体测试,就会发现错误了。
int doublew(x) int x; { return x*2; }
int triple( y) int y; { return y*3; }
main() { int i = 2; int a, b;
a = doublew(i++); b = triple(i++); printf("Got %d \n", a+b); } |
例4:一个变体
在变体测试中,最让人惊奇的是,它能找出正常看来是不可能检测到的错误,通常,这些错误隐藏得很深,直到程序崩溃时,才可能发现,但对此,程序员经常不能理解。同等变体是找出错误的机会,而不是其他。但普遍来说,程序员期望同等变体能得出与原程序一样的结果,但如果总是这样的话,那同等变体是没有任何作用了。
第2步:当清除最致命的错误之后,要把那些可能会出错的代码在移植之前,用静态分析工具再确认一遍。在静态分析时,有两个主要的工作要做:
·找出并修正那些移植到新平台之后可能会出错的代码
·找出并修正那些可能不能很好地被移植的代码 首先,要用业界推荐的C/C++编码标准来检查那些可能在新平台上出错的代码,以确认其编码结构没有问题。通过确认代码符合编码标准,可防止不必要的错误发生,还能减少在新平台上的调试工作量,并降低在最终产品中出现bug的机率。
以下是一些可用的编码标准:
不要返回对一个局部对象或对在函数内用"new"初始化的指针
的引用。对一个局部对象返回一个引用,可能会导致堆栈崩溃;而返回一个对在函数内用"new"初始化的指针的引用,可能会引起内存泄漏。
不要转换一个常量到非常量。这可能会导致数值被改变,从而破坏数据的完整性。这也会降低代码的可读性,因为你不能再假定常量不被改变。
如果某个类有虚拟成员函数,它最好也带有一个虚拟析构函数。这能在继承类中防止内在泄漏。带有任何虚拟成员函数的类,通常被用作基类,此时它应有一个虚拟析构函数,以保证继承类通过一个指向基类的指针来引用时,相应的析构函数会被调用。
公共成员函数必须为成员数据返回常量句柄。当把一个非常量的句柄提供给成员数据时,此时调用者可在成员函数之外修改成员数据,这就破坏了类的封装性。
不要把指向一个类的指针,转换成指向另一个类的指针,除非它们之间有继承关系。这种无效的
转换将导致不受控的指针、数据崩溃等问题,或者其他错误。
不要从一个构造函数中直接访问一个全局变量。C++语言的定义之中,没有规定在不同的代码单元中定义的静态对象初始化的顺序。因此,在从一个构造函数中访问一个全局变量时,这个变量可能还没有初始化。
当找到并修正有错误的代码之后,从那些在当前平台上运行良好的代码中再继续找,因为它们可能不能被很好地移植。以下是一些对大多数64位移植项目都适用的规则:
尽量使用标准类型。比如说,使用size_t而不是int。如果想要一个无符号的64位int,那么请使用uint64_t。这个习惯不但有助于找出和防止代码中的bug,还能在将来向128位处理器移植程序时,帮上大忙。
检查现有代码中long数据类型的用法。如果变量、域、参数中数值的变化范围,只在2Gig-1到-2Gig或4Gig到0之间,那么最好分别使用int32_t或uint32_t。
检查所有的"窄向"赋值。应该避免这种情况出现,因为把一个long赋值给一个int,在64位数值上会导致截断。
找出"窄向"转换。应只在表达式中使用窄向转换,而不是在操作数中。
找出那些把long*转换成int*,或把int*转换成long*的地方。在32位环境下,这也许是可交替的,但在64位中不行,并检查所有的不匹配指针赋值。
找出那些在乘法符号的两端,没有long操作数的表达式。要使int型表达式将产生64位结果,至少其中的一个操作数是long或unsigned long。
找出long型值用int初始化的地方。应避免这种类型的初始化,因为甚至在64位类型的表达式中,int常量也可能只是代表一个32位类型。
找出那些对int进行移位操作,又把结果赋给long的地方。如果结果是64位值,最好使用64位乘法。
找出那些64位表达式中的int常量。在64位表达式中应使用64位值。
找出把指针转换成int的地方。涉及指针与int互转换的代码,应仔细检查。
检查内联汇编语句。因为它不可能被很好地移植。
第3步:重复一遍运行时错误检测,以确认所有的修改都没有引入新的运行时错误。
第4步:此时,你可选择进行更多的测试步骤,以保证在移植之前,所有的代码都完全正确。这个额外的步骤是单元测试,单元测试是在每一个软件单元完成之后进行的传统测试,它在开发阶段的后期,也是有益的。因为在单元级别,很容易设计出每个函数的输入,它将有助于更快地找出那些在应用级别测试中无法发现的错误。
找出64位处理器上的问题
也许64位处理器本身就有问题,如果是这样的话,下面的步骤应该有用: 第1步:在64位处理器上重新编译应用程序。在编译中如果有问题,应考虑是不是因编译器的不同而产生的。
第2步:一旦重新编译代码,应进行代码检查,以确保新代码都遵循适当的编码标准。在这一点上,任何人都不希望每一次修改都带来一个错误,此时解决好过在程序运行时才发现。
第3步:链接并生成应用程序。
第4步:应试着运行程序。如果在64位处理器上,运行程序时发现了问题,应使用单元测试方法一个函数一个函数地去找,这样能确定哪些代码没有正确地被移植;修正这些问题直到程序可以运行。
第5步:重复运行时错误检测。
一旦程序可以运行,一定要重复一遍运行时错误检测,因为移植过程很可能导致新的问题产生,例如新的内存崩溃或程序工作方式有所不同。如果运行时错误检测发现了错误,那么此时赶快修正它。
结论
遵循此文中提及的方法,可在程序发布之前,找到并修正C/C++内存错误,并可以节省下数周的调试时间,使用户免受"灾难"之苦。