软件调试及单元测试

对于很多程序员朋友来说,编写代码要比调试代码快乐的多。似乎创造软件比维护软件更能给人带来成就感。然而,在企业里面维护前人留下的代码也是工作中不可缺少的一项内容。所以,如何调试软件,更快更好地寻找软件中的bug,就成了我们必须学习的一门功课。当然,有人查找故障很快,而有的人却要慢一点,这中间的原因很多,比如说对业务的熟悉程度,对调试工具的使用程度。这也从一方面说明了,掌握软件调试的技巧是十分重要的。这里讨论的内容,不是指怎么用visual studio或者是gdb、kgdb、systemtap调试,而是说说调试软件的基本原理是什么。说到底,调试软件也是软件,它需要芯片、操作系统、编译软件、堆栈格式的支持。

(1)芯片的支持

    很多朋友都喜欢在软件运行的过程中设置断点,比如说在代码中插入一个__asm__ ("int $3" ::)就可以达到这样的效果。关键是为什么插入这个代码就会有这样的效果。原来在x86芯片中,上面的int 3会被翻译成0xCC。当cpu遇到这样一行指令的时候,自身就会产生异常,进而会查找相应的异常函数进行处理。在x86中是存在专门的调试指令的,但是在某些cpu中这样的指令却未必存在,比如说powerpc,那这个时候怎么办呢?其实也简单,只需要把对应的指令替换成cpu不认识的指令即可,这样同样可以产生异常的效果。至于当前异常是不是调试异常,那么就需要操作系统来判断了。

(2)操作系统的支持

    芯片本身只是负责产生异常,寻找到异常处理函数,至于这样的函数还要做些什么,那就是操作系统要负责的事情了。比如说,现在我们要调试的程序已经运行到断点了,那么操作系统就要通知gdb或者visual studio当前的程序已经运行到节点了,接下来要做什么。在一般的软件调试中,功能其实都大同小异,比如说查看寄存器、查看内存、设置断点、取消断点、查看线程号、继续运行等等。这些都需要操作系统本身的支持,否则作为用户侧的gdb怎么知道当前的调试程序运行到什么地方了,它的基本信息在哪里等等。毕竟,gdb本身也是一个软件,它关于调试程序的具体信息都是别人告诉它的,它又不是神。

(3)编译软件的支持

    有了芯片和操作系统的支持,其实就可以调试软件了,比如说wingdb就是这么干的。但是,我们还不是很满足,为什么?因为有的时候,我们还需要知道函数的参数值、全局变量是多少,有没有发生改变,C语言代码有没有对应的汇编代码,能否实现汇编级的调试等等。当然,这些信息对于执行文件本身的运行其实是无关紧要的,只是我们为了调试软件的时候使用的。所以,在visual studio编译的软件版本当中有Debug版本,有release版本之分;有普通的软件版本,有优化的软件版本。在linux上,人们为了调试的需要,也会在gcc调试的选项中添加-g选项,获得额外的调试信息。

(4)堆栈的支持

    在调试软件中,有一项非常棒的内容,那就是函数堆栈查看功能。堆栈会根据临时变量的添加、减少进行浮动处理。可以说掌握了堆栈就掌握了cpu、掌握了编程、掌握了软件调试。在x86中,ebp是比较神奇的寄存器。在堆栈中,ebp[0]保存了上一个ebp的地址,ebp[1]保存了返回函数的地址。通过迭代,就可以的得到所有的函数指针了。当然,通过编译器可以生成软件的systemp map文件,也就记录所有函数的空间地址。把返回地址和system map联系在一起,我们就可以知道当前代码的函数堆栈了。除此之外,我们还可以利用堆栈中的返回地址设置断点,这样可以在当前函数运行到结束的时候断住,使用起来也是十分方便。

[cpp] view plain copy

  1. void print_function_addr(int ebp, int level)  
  2. {  
  3.     int* start;  
  4.     int index;  
  5.     if(0 == ebp || 0 == level)  
  6.         return;  
  7.   
  8.     start = (int*)ebp;  
  9.     index = 0;  
  10.     while(index < level){  
  11.         printf("[%d] 0x%08x\n", index, start[1]);  
  12.         index ++;  
  13.         start = (int*)start[0];  
  14.     }  
  15. }  

(5)日志和计数器

    依靠系统本身的调试软件当然是不够的,所以为了看清数据的运行流程,我们还需要获得一些额外的调试信息了。所以,对于业务而言,我们需要按照告警、错误、数据格式分别进行日志保存。当然,有时候我们还要对业务性能、频率进行衡量和比较,所以很多时候计数器也是必不可少的。这些信息都是正常代码之外的额外信息,所以处理好他们和普通代码之间的关系也是一门大学问。同时,日志有时还要受到多线程、效率、异常等因素的影响,所以思考和执行的时候一定要顾虑周全。

(6)调试原则

    这些调试原则只是我个人的一些经验总结,谈不上真知灼见,仅供大家参考。1)先排除硬件,后软件。特别高频信号要注意时序和信号完整性;2)软件设计大于调试;3)软件早调试早得益;4)复杂的功能调试可以在仿真软件上进行,比如说全局地址越界等,使用gdb的条件断点调试就十分方便,在嵌入式设备上查找却十分困难;5)尽量自己编写调试函数,不断改进和优化处理,用得也顺手;6)日志模块要健壮,尤其要适合多线程处理;7)定位故障一定要寻找到根本原因,否则极易生成新的故障。

 很长时间以来,自己就想写一篇关于单元测试的文章。但是由于自己在某些方面思考得不是很成熟,再加上前一段时间稍微有点忙,所以这个事情就一直这样耽搁下来了。其实,朋友们在开发的时候都知道单元测试是个好东西,但是真正用于实践,并且在开发中一直保持下去的却是少数。虽然单元测试的框架很多,什么CUnit,CxxUnit等存在很多现成的开源代码框架,但是大家使用起来还不是很习惯。至于大家为什么会对单元测试很抵触,我想这主要有几个方面的原因:(1)单元测试会在无形中增加自己的代码开发量;(2)程序员们缺少软件质量的意识,认为保证软件质量是软件测试部门的事情;(3)单元测试的效果无法在短期内有所体现,不如功能开发那样立竿见影;(4)大家习惯了开发、编译、调试、上机测试、修改这样的传统的开发方式;(5)项目至上而下缺少质量控制意识,片面追求开发速度、功能数量、入库行数并过度依赖集成测试。

    但是,这里我想说的是每一个程序员都必须对自己的代码负责,不管这段代码是你设计的还是你维护的。单元测试就是一种很好的验证你代码质量的方法。无论是在设计测试用例、理解代码设计、新功能开发、系统理解方面,单元测试都会对你有所帮助。但是,不可否认,单元测试对个人的要求还是很高的,这就需要个人一点点去适应、去改变。

    a)头文件仿真

    在单元测试中,为了调用很多的底层函数,通常我们会对某些头文件进行仿真。这个时候,我们引用的函数完全是自己定义和设计的。但是,我们也不能为了现在的测试修改原来的头文件排布。所以,这个时候就需要对原有的头文件进行仿真。现在,我们假设原来会引用到一个data_type.h文件,中间有我们需要的函数声明,但是现在不需要了。这时候,我们就可以自己定义一个空的data_type.h文件,添几行代码就可以了。

[cpp] view plain copy

  1. #ifndef _DATA_TYPE_H  
  2. #define _DATA_TYPE_H  
  3.   
  4. #endif  

    b)数据处理流程和上层接口分开

    我们在安排源文件的时候,在安排函数分布的时候要注意一个基本的原则:数据和上层接口分开。在单元测试的时候,我们不太在乎曾经将这个数据的上层包装形式是什么。只有真正把数据从结构从释放出来,形成一个独立的处理文件,这样我们的测试才更方便、更有针对性。小函数、独立函数、与接口分离的函数,这些都是我们在代码开发中需要特别注意的。

    c)底层驱动打桩处理

    在真实的软件模块中,我们的代码是不可能独立存在的,因此当前模块的代码常常需要引用别的模块代码。建立符合自己模块的桩函数,一方面可以提高代码的开发效率,另外一方面也方便我们对自身的代码进行测试。当然,底层驱动打桩函数是多种多样的,某些配置类的函数我们可以象征地输出一行打印就可以了;某些函数我们可以利用测试端的一个相似函数代替即可;另外本地不存在的一些函数可能还需要我们真正编写代码仿真一把。

    d)测试用例应该尽量和实际环境一致

    为了验证代码的正确性,编写测试用例当然是少不了的。但是,编写测试用例并不是说越多越好。重复、低质量的测试用例只会浪费我们的测试资源。那么,应该怎么做呢?其实真实的运行场景才是我们所关注的。对于我们来说,最重要的就是把那些基础功能、使用最多的功能、最容易犯错的功能设计成测试用例,剩下的测试用例才是关于覆盖率、性能方面的。

    e)重视代码覆盖率,更加重视功能覆盖率

    在开发中,很多开发者甚至领导都会把代码测试覆盖率当作单元测试很重要的一个条件。诚然,高的代码覆盖率固然能说明一些问题,但那不是问题的全部。我们进行单元测试的目的主要是为了验证功能实现和设计是否一致,不是为了测试而测试。当然,在测试中我们可以仿真很多的条件,90%甚至更高的代码覆盖率都是有可能的。但是,我们需要问一下自己,这些测试和最后的功能测试关系很大吗?如果没有这些测试,会影响最后的功能测试吗?我们假设的这些单元测试条件在实际运行的时候是真实存在的吗?

    f)多线程测试需要日志保存,在程序崩溃的时候生成dump文件

    有些功能的开发,是需要同时运行多个线程的。因此对于某些关键的配置,由于无法保证代码的执行顺序,我们需要对执行过程进行日志记录。如果程序在运行的过程中发生了崩溃,我们也需要及时对关键数据进行记录,保存系统生成的dump文件。

    g)测试用例注意生成环境和清除环境

    使用过CUnit的朋友想必对××_init和××_clean非常熟悉。××_init是为了给我们的测试用例构建测试环境,而××_clean则是对当前的测试环境进行清理,这样不至于对下面一个测试造成影响。本质上说,CUnit干的就是这么一件事情,在测试用例运行前,自动调用你的**_init函数,执行结束后自动调用你的**_clean函数。如果你不使用现成的测试框架也没有关系,但是你在测试代码的时候也需要注意环境的生成和清理问题。

   h)测试代码也需要保存、重构、模块、分层设计

    测试代码也不是一层不变的,有的时候为了适应代码的重构需要,我们也需要对测试代码进行重构处理。比如说,有些代码我们是用来测试函数级别的基本功能的,有些代码我们是测试模块功能的,有的代码我们是测试函数性能的,这些测试代码都需要分开。另外,测试也会按照函数调用顺序不断增加测试的难度和复杂度的,所以测试代码的分层设计也是十分必要的。

    i)运行带测试用例的实际版本

    在版本release的时候,我们是绝不可能在实际版本中存在测试代码的。但是在开发的时候,我们可以自己编译生成带测试代码的版本。所以,我们需要做的就是保证我们的测试代码不但可以在本地单元测试通过,还需要在实际环境通过。如果在这两方面都能通过的话,那么才能说明我们的测试是成功的,我们测试是有保障的,否则即使在本地做好了单元测试又有什么意义呢?

猜你喜欢

转载自blog.csdn.net/dijuzhu2350/article/details/81811048