测试

在笔者漫长的开发生涯中, 许多人在向笔者请教问题的时候, 总是喜欢重复这样一句话:"我的代码没有错呀, 为什么结果不对呢?". 在一次又一次的实践中, 笔者得出了以下几条"公理":

  • 计算机永远是对的.
  • 如果你的预期结果和计算机给出的结果不一致, 一定是你错了.
  • 未测试的代码永远是错的.
  • 你可以不相信以上几条公理, 但未来你一定会再遇到麻烦.

上面带一点玩笑成分, 但测试对于代码质量来说真的非常重要! 目前有许多测试理论, 比如"测试驱动开发"之类, 私以为大部分情况下并不建议太过于拘泥这些理论, 开发者需寻找适合自己的测试之道. 但仍然有一些通用准则适用大部分开发者.

  • 测试的目的是证明系统存在缺陷, 而不是证明缺陷不存在. 这个原则最早是由图灵奖获得者 Edsger W. Dijkstra 在 1969 年提出来的. 测试可以降低系统中遗留的缺陷未被发现的可能性, 但是即使没有发现缺陷, 也不能证明系统是完全正确的.
  • 完美的测试是不存在的. 这个原则非常重要, 因为正是因为它引发了许多测试理念, 比如边界值测试: 如果一个函数接收一个有符号整数作为参数, 开发者通常可以用以下数据对其进行测试: 一个负数, 零, 一个正数, 有符号整数的上边界和有符号整数的下边界. 遍历全部的有符号整数的范围是相当愚蠢的.
  • 不能通过打补丁的方式修复缺陷. 当测试发现问题, 请尽量不要通过一个 If 语句去 Fix 这个测试. 开发者应该关注缺陷的根本成因, 而不应该为了通过测试而盲目修改代码.
  • 应尽早展开测试. 有如下一个公式: 在单元测试期间修复一个 Bug 的代价是 1, 在集成测试期间修复一个 Bug 的代价是 10, 而在黑盒测试期间修复一个 Bug 的代价是 100.
  • 缺陷的分布存在 2/8 定律. 80% 的 Bug 分布在 20% 的代码里, 应该把测试重点放在这 20% 的代码部分.

学会正确的测试方法是每个开发者必须掌握的技能.

调试概述

对于本书的仿真器而言, 如果在执行一个测试用例失败的时候, 需要对仿真器进行调试. 如果仿真器没有对外提供调试功能, 那么一旦程序未按照开发者预想的过程运行, 开发者就将处于一个束手无策的地步. 因此, 调试功能对于仿真器而言至关重要.

此处只对最容易出问题的 CPU 进行举例. 对于现实中的处理器的调试功能而言, 常用的是两种:

  • 交互式调式
  • 追踪调试

下面将对这两种调试功能进行概述. 但要注意的是, 现实中的处理器的调试功能是个非常复杂的模块, 其实现难度甚至超过了处理器本身(非夸张!), 本节将只对其原理进行简述.

交互式调试

交互式调试(Interactive Debug)是处理器提供的最常见一种调试功能. 它指调试软件能够直接取得处理器的控制权, 进而对其进行交互式调试的一种机制. 一个非常常见的例子是著名的调试软件 GDB, 以及常用的断点调试法.

在交互式调试下, 使用者被允许随时暂停/启动处理器运行, 实时查看处理的全部状态, 甚至是改变处理器的运行状态, 比如修改寄存器中的值.

调试软件需要取得处理器的控制权, 那么首先处理器必须向外提供这个功能才行. 如果硬件不支持, 那么一切都是白搭. 在绝大部分处理器中, 都有一个硬件实现的调试模块, 该模块通过物理介质与主机端的调试软件进行通信并接受调试软件的控制, 然后调试模块再对处理器进行控制.

此处使用一个例子以帮助读者理解这个过程, 以使用 GDB 调式 C 程序为例子. 让我们创建以下 C 程序, 该程序计算并打印数字的阶乘. 但是, 出于调试目的, 此 C 程序中包含一些错误.

# include <stdio.h>

int main()
{
    int i, num, j;
    printf ("Enter the number: ");
    scanf ("%d", &num );

    for (i=1; i<=num; i++)
        j=j*i;

    printf("The factorial of %d is %d\n",num,j);
}

编译并执行程序, 发现运行结果存在错误.

$ cc factorial.c
$ ./a.out
Enter the number: 3
The factorial of 3 is 12548672

使用 GDB 对其进行调试. 使用调试选项 -g 编译 C 程序 gcc -g factorial.c. -g 选项的作用是在可执行文件中加入源代码的信息, 比如可执行文件中第几条机器指令对应源代码的第几行, 但并不是把整个源文件嵌入到可执行文件中, 所以在调试时必须保证 gdb 能找到源文件. 如果把当前的 factorial.c 改名或者移动到其他地方, 则 gdb 无法进行调试.

之后运行 gdb a.out 设置断点, 语法是 break line_number. 这里设置断点为第十行, 即 break 10.

Breakpoint 1, main () at factorial.c:10
10          j=j*i;

此时便可以打印变量的值, 语法是 print variable_name.

(gdb) p i
$1 = 1
(gdb) p j
$2 = 3042592
(gdb) p num
$3 = 3

如上所示, 可以发现在 factorial.c 中, 由于尚未初始化变量 j 因此它得到了错误的阶乘值.

交互式调试能解决大量问题, 同时它也已经存在了几十年了. 但其存在一个缺点是对处理器和开发者都具有打扰性. 它通过牺牲调式效率来降低学习门槛, 本质上是一种极其低效的调试方法.

在现代互联网开发中, 已经很少见到有人大量使用交互式调式来解决 Bug, 更多的是通过输出日志的方式来定位和解决 Bug. 交互式调式需要在代码中插入额外的信息, 它必定会影响代码运行效率, 但同时现代软件工程复杂度远远超过过去, 一个二进制文件到达几百兆是非常普遍的, 交互式调试因为其调试范围小(变量级别的), 调试步骤麻烦等原因在互联网行业被逐渐淘汰.

跟踪调试

跟踪调试(Trace Debug)机制是为了解决交互式调试对处理器的打扰性而发明出来的调试方式. 跟踪调试的本质就是日志机制, 调试器将跟踪记录处理器执行过的所有指令以及该指令执行时所有寄存器的值, 而不会打断干扰处理器本身的运行过程. 跟踪调试同样需要硬件支持才能做到, 相比交互式调试的硬件实现难度更大, 消耗资源更高. 由于处理器是以极快的速度运行的, 它每秒产生的数据量非常庞大, 跟踪处理器需要记录下所有指令, 对于调试模块的性能要求, 数据传输, 数据存储都是巨大的挑战.

但对于 Game Boy 仿真器而言, 其缺点将被完全掩盖, 因为 Game Boy 的 CPU 本身处理速度不高, 同时其寄存器宽度小, 寄存器数量少等特点, 它产生的数据量是有限, 就笔者之前的测试来看, 3 分钟大概只产生了 20G 的未压缩的数据, 对于 Game Boy 的普遍只需要花费 1 ~ 10 秒的测试用例而言, 完全在可接受范围之内.

为仿真器添加跟踪调试的方式非常简单, 只需要在 CPU 模块加入一行代码即可:

println!("b={} c={} d={} e={} h={} l={} a={} f={}", self.reg.b, self.reg.c, self.reg.d, self.reg.e, self.reg.h, self.reg.l, self.reg.a, self.reg.f)

剩下的工作就是接收仿真器的标准输出到一个本地文件.

和外挂的调试工具相比, 日志具备良好的回溯查询能力. 读者可以使用同样的方式在其他的开源 Game Boy 仿真器中插入日志并输出, 运行同一个测试用例, 并比对两份日志的不同以此寻找出自己代码中可能存在错误的地方.

开源测试集

此处将采用开源的 Game Boy 硬件测试集来验证目前实现的仿真器. 目前普遍的测试 Game Boy 硬件/仿真器的方式是通过一些开发者开发的测试 ROM. 在以前, 测试 ROM 是验证 Game Boy 硬件总体质量的关键步骤, 但是现在它们更多被用于仿真器上. 目前存在一些大型的 Game Boy 测试套件, 比如有以下 4 种不同的测试套件:

  • Blargg's test ROMs
  • Gambatte test ROMs
  • AntonioND's test ROMs
  • Mooneye GB test ROMs

如果读者准备开源自己的 Game Boy 实现, 请同时声明该开源实现通过了哪些测试集. 这可以大量节省他人的时间. 许多开发者已经测试了这些 ROM 的正确性, 因此在进行测试时, 应当假设这些测试 ROM 是正确的.

这里以"Blargg's test ROMs"测试集为例子, 这个测试集通常被认为是 Game Boy 的基础测试套件, 但即使如此, 本书介绍的仿真器实现仍然没有通过此套件中的一些测试用例. 已确认大概率没有问题的模块分别是 CPU, Cartridge 等模块, 而在视频, 音频部分少部分测试用例未能通过. 不过它们并不影响仿真器的运行, 因为在一帧中出现的问题画面或音频问题将在下一帧被重置.

https://github.com/retrio/gb-test-roms 下载全部测试用例, 将它们当作一个普通的游戏使用仿真器加载并运行查看结果.

不过需要记住一点, 在测试过程中不要假设其它仿真器是正确的. 在对代码的测试过程中, 一定会出现很多 Bug, 这几乎是必然的. 读者可以下载其它的开源仿真器实现, 并参照/理解它们的代码来修正自己代码中的错误, 但请不要假设这些开源仿真器一定是正确的. 同时, 测试集中包含许多模块的独立测试, 尽量一次只关注一件事, 避免交叉测试代码. 比如在测试 Game Boy CPU 指令集的时候, 不要去试图修改视频或音频的代码. 目前比较推荐的测试顺序是:

  1. Cartridge 模块
  2. CPU 指令集
  3. CPU 中断
  4. GPU
  5. 游戏手柄
  6. 音频, 串行接口及其他

其中音频, 串行接口及其他模块即使存在 Bug 也不会影响 Game Boy 仿真器的整体运行, 因此可以尽量降低它们的优先级.