从PA中记录的 C语言 调试与测试 的建议、技巧

调试其实就是从观测到的failure一步一步回溯寻找fault的过程, 找到了fault之后, 我们就很快知道应该如何修改错误的代码了. 但从上面的例子也可以看出, 调试之所以不容易, 恰恰是因为:

  • fault不一定马上触发error
  • 触发了error也不一定马上转变成可观测的failure
  • error会像滚雪球一般越积越多, 当我们观测到failure的时候, 其实已经距离fault非常遥远了

理解了这些原因之后, 我们就可以制定相应的策略了:

  • 尽可能把fault转变成error. 这其实就是测试做的事情, 所以我们在上一节中加入了表达式生成器的内容, 来帮助大家进行测试, 后面的实验内容也会提供丰富的测试用例. 但并不是有了测试用例就能把所有fault都转变成error了, 因为这取决于测试的覆盖度. 要设计出一套全覆盖的测试并不是一件简单的事情, 越是复杂的系统, 全覆盖的测试就越难设计. 但是, 如何提高测试的覆盖度, 是学术界一直以来都在关注的问题.
 

尽早观测到error的存在. 观测到error的时机直接决定了调试的难度: 如果等到触发failure的时候才发现error的存在, 调试就会比较困难; 但如果能在error刚刚触发的时候就观测到它, 调试难度也就大大降低了. 事实上, 你已经见识过一些有用的工具了:

  • -Wall-Werror: 在编译时刻把潜在的fault直接转变成failure. 这种工具的作用很有限, 只能寻找一些在编译时刻也觉得可疑的fault, 例如if (p = NULL). 不过随着编译器版本的增强, 编译器也能发现代码中的一些未定义行为. 这些都是免费的午餐, 不吃就真的白白浪费了.
  • assert(): 在运行时刻把error直接转变成failure. assert()是一个很简单却又非常强大的工具, 只要在代码中定义好程序应该满足的特征, 就一定能在运行时刻将不满足这些特征的error拦截下来. 例如链表的实现, 我们只需要在代码中插入一些很简单的assert()(例如指针解引用时不为空), 就能够几乎告别段错误. 但是, 编写这些assert()其实需要我们对程序的行为有一定的了解, 同时在程序特征不易表达的时候, assert()的作用也较为有限.
  • printf(): 通过输出的方式观察潜在的error. 这是用于回溯fault时最常用的工具, 用于观测程序中的变量是否进入了错误的状态. 在NEMU中我们提供了输出更多调试信息的宏Log(), 它实际上封装了printf()的功能. 但由于printf()需要根据输出的结果人工判断是否正确, 在便利程度上相对于assert()的自动判断就逊色了不少.

  。 GDB: 随时随地观测程序的任何状态. 调试器是最强大的工具, 但你需要在程序行为的茫茫大海中观测那些可疑的状态, 因此使用起来的代价也是最大的.  

 

gdb 在运行会触发段错误的程序时,会停在触发段错误的那一行上

 

 sanitizer - 一种底层的assert

段错误一般是由于非法访存造成的, 一种简单的想法是, 如果我们能在每一次访存之前都用assert()检查一下地址是否越界, 就可以在段错误发生之前捕捉到error了!

虽然我们只需要重点关注指针和数组的访问, 但这样的代码在项目中有很多, 如果要我们手动在这些访问之前添加assert(), 就太麻烦了. 事实上, 最适合做这件事情的是编译器, 因为它能知道指针和数组的访问都在哪里. 而让编译器支持这个功能的是一个叫Address Sanitizer的工具, 它可以自动地在指针和数组的访问之前插入用来检查是否越界的代码. GCC提供了一个-fsanitize=address的编译选项来启用它. menuconfig已经为大家准备好相应选项了, 你只需要打开它:

Build Options
  [*] Enable address sanitizer

然后清除编译结果并重新编译即可.

你可以尝试故意触发一个段错误, 然后阅读一下Address Sanitizer的报错信息. 不过你可能会发现程序的性能有所下降, 这是因为对每一次访存进行检查会带来额外的性能开销. 但作为一个可以帮助你诊断bug的工具, 付出这一点代价还是很值得的, 而且你还是可以在无需调试的时候将其关闭.

事实上, 除了地址越界的错误之外, Address Sanitizer还能检查use-after-free的错误 (即"释放从堆区申请的空间后仍然继续使用"的错误), 你知道它是如何实现这一功能的吗?

 更多的sanitizer

事实上, GCC还支持更多的sanitizer, 它们可以检查各种不同的错误, 你可以在man gcc中查阅-fsanitize相关的选项. 如果你的程序在各种sanitizer开启的情况下仍然能正确工作, 就说明你的程序还是有一定质量的.

根据上面的分析, 我们就可以总结出一些调试的建议:

  • 总是使用-Wall-Werror
  • 尽可能多地在代码中插入assert()
  • 调试时先启用sanitizer
  • assert()无法捕捉到error时, 通过printf()输出可疑的变量, 期望能观测到error
  • printf()不易观测error时, 通过GDB理解程序的精确行为

如果你在程序设计课上听说过上述这些建议, 相信你几乎不会遇到运行时错误.

 

posted @ 2022-08-03 09:32  yinhuachen  阅读(116)  评论(0编辑  收藏  举报