监视点

监视点的功能是监视一个表达式的值何时发生变化. 如果你从来没有使用过监视点, 请在GDB中体验一下它的作用.

扩展表达式求值的功能

你之前已经实现了算术表达式的求值, 但这些表达式都是由常数组成的, 它们的值不会发生变化. 这样的表达式在监视点中没有任何意义, 为了发挥监视点的功能, 你首先需要扩展表达式求值的功能.

我们用BNF来说明需要扩展哪些功能:

<expr> ::= <decimal-number>
  | <hexadecimal-number>    # 以"0x"开头
  | <reg_name>              # 以"$"开头
  | "(" <expr> ")"
  | <expr> "+" <expr>
  | <expr> "-" <expr>
  | <expr> "*" <expr>
  | <expr> "/" <expr>
  | <expr> "==" <expr>
  | <expr> "!=" <expr>
  | <expr> "&&" <expr>
  | "*" <expr>              # 指针解引用

它们的功能和C语言中运算符的功能是一致的, 包括优先级和结合性, 如有疑问, 请查阅相关资料.

关于获取寄存器的值, 这显然是一个ISA相关的功能. 框架代码已经准备了如下的API:

// nemu/src/isa/$ISA/reg.c
word_t isa_reg_str2val(const char *s, bool *success);

它用于返回名字为s的寄存器的值, 并设置success指示是否成功.

还需要注意的是指针解引用(dereference)的识别, 在进行词法分析的时候, 我们其实没有办法把乘法和指针解引用区别开来, 因为它们都是*. 在进行递归求值之前, 我们需要将它们区别开来, 否则如果将指针解引用当成乘法来处理的话, 求值过程将会认为表达式不合法. 其实要区别它们也不难, 给你一个表达式, 你也能将它们区别开来. 实际上, 我们只要看*前一个token的类型, 我们就可以决定这个*是乘法还是指针解引用了, 不信你试试? 我们在这里给出expr()函数的框架:

if (!make_token(e)) {
  *success = false;
  return 0;
}

/* TODO: Implement code to evaluate the expression. */

for (i = 0; i < nr_token; i ++) {
  if (tokens[i].type == '*' && (i == 0 || tokens[i - 1].type == certain type) ) {
    tokens[i].type = DEREF;
  }
}

return eval(?, ?);

其中的certain type就由你自己来思考啦! 其实上述框架也可以处理负数问题, 如果你之前实现了负数, *的识别对你来说应该没什么困难了.

另外和GDB中的表达式相比, 我们做了简化, 简易调试器中的表达式没有类型之分, 因此我们需要额外说明两点:

  • 所有结果都是uint32_t类型.
  • 指针也没有类型, 进行指针解引用的时候, 我们总是从客户计算机的内存中读出一个uint32_t类型的整数.
扩展表达式求值的功能

你需要实现上述BNF中列出的功能. 上述BNF并没有列出C语言中所有的运算符, 例如各种位运算, <=等等. ==&&很可能在使用监视点的时候用到, 因此要求你实现它们. 如果你在将来的使用中发现由于缺少某一个运算符而感到使用不方便, 到时候你再考虑实现它.

riscv64中的表达式求值

由于riscv64是一个64位的ISA, 其寄存器的值是64位的, 因此为了读出正确的寄存器值, 你需要把表达式求值的结果解释成uint64_t类型.

测试的局限

我们之前实现了一个表达式生成器, 但给表达式求值加入了寄存器使用和指针解引用这两个功能之后, 表达式生成器就不能满足我们的所有需求了. 这是因为在C程序中, 寄存器的语义并不存在, 而指针解引用的语义则与NEMU大不相同.

这里想说明的是, 测试也会有局限性, 没有一种技术可以一劳永逸地解决所有问题. 前沿的研究更是如此: 它们很多时候只能解决一小部分的问题. 然而这个表达式生成器还是给你带来了很大的信心, 去思考如何方便地测试, 哪怕是进行一部分的测试, 也是有其价值的.

实现监视点

简易调试器允许用户同时设置多个监视点, 删除监视点, 因此我们最好使用链表将监视点的信息组织起来. 框架代码中已经定义好了监视点的结构体(在nemu/src/monitor/sdb/watchpoint.c中):

typedef struct watchpoint {
  int NO;
  struct watchpoint *next;

  /* TODO: Add more members if necessary */

} WP;

但结构体中只定义了两个成员: NO表示监视点的序号, next就不用多说了吧. 为了实现监视点的功能, 你需要根据你对监视点工作原理的理解在结构体中增加必要的成员. 同时我们使用"池"的数据结构来管理监视点结构体, 框架代码中已经给出了一部分相关的代码:

static WP wp_pool[NR_WP] = {};
static WP *head = NULL, *free_ = NULL;

代码中定义了监视点结构的池wp_pool, 还有两个链表headfree_, 其中head用于组织使用中的监视点结构, free_用于组织空闲的监视点结构, init_wp_pool()函数会对两个链表进行初始化.

实现监视点池的管理

为了使用监视点池, 你需要编写以下两个函数(你可以根据你的需要修改函数的参数和返回值):

WP* new_wp();
void free_wp(WP *wp);

其中new_wp()free_链表中返回一个空闲的监视点结构, free_wp()wp归还到free_链表中, 这两个函数会作为监视点池的接口被其它函数调用. 需要注意的是, 调用new_wp()时可能会出现没有空闲监视点结构的情况, 为了简单起见, 此时可以通过assert(0)马上终止程序. 框架代码中定义了32个监视点结构, 一般情况下应该足够使用, 如果你需要更多的监视点结构, 你可以修改NR_WP宏的值.

这两个函数里面都需要执行一些链表插入, 删除的操作, 对链表操作不熟悉的同学来说, 这可以作为一次链表的练习.

温故而知新

框架代码中定义wp_pool等变量的时候使用了关键字static, static在此处的含义是什么? 为什么要在此处使用它?

实现了监视点池的管理之后, 我们就可以考虑如何实现监视点的相关功能了. 具体的, 你需要实现以下功能:

  • 当用户给出一个待监视表达式时, 你需要通过new_wp()申请一个空闲的监视点结构, 并将表达式记录下来. 然后在trace_and_difftest()函数(在nemu/src/cpu/cpu-exec.c中定义)的最后扫描所有的监视点, 每当cpu_exec()的循环执行完一条指令, 都会调用一次trace_and_difftest()函数. 在扫描监视点的过程中, 你需要对监视点的相应表达式进行求值(你之前已经实现表达式求值的功能了), 并比较它们的值有没有发生变化, 若发生了变化, 程序就因触发了监视点而暂停下来, 你需要将nemu_state.state变量设置为NEMU_STOP来达到暂停的效果. 最后输出一句话提示用户触发了监视点, 并返回到sdb_mainloop()循环中等待用户的命令.
  • 使用info w命令来打印使用中的监视点信息, 至于要打印什么, 你可以参考GDB中info watchpoints的运行结果.
  • 使用d命令来删除监视点, 你只需要释放相应的监视点结构即可.
实现监视点

你需要实现上文描述的监视点相关功能, 实现了表达式求值之后, 监视点实现的重点就落在了链表操作上.

由于监视点的功能需要在cpu_exec()的每次循环中都进行检查, 这会对NEMU的性能带来较为明显的开销. 我们可以把监视点的检查放在trace_and_difftest()中, 并用一个新的宏 CONFIG_WATCHPOINT把检查监视点的代码包起来; 然后在nemu/Kconfig中为监视点添加一个开关选项, 最后通过menuconfig打开这个选项, 从而激活监视点的功能. 当你不需要使用监视点时, 可以在menuconfig中关闭这个开关选项来提高NEMU的性能.

在同一时刻触发两个以上的监视点也是有可能的, 你可以自由决定如何处理这些特殊情况, 我们对此不作硬性规定.

调试工具与原理

在实现监视点的过程中, 你很有可能会碰到段错误. 如果你因此而感觉到无助, 你应该好好阅读这一小节的内容.

我们来简单梳理一下段错误发生的原因. 首先, 机器永远是对的. 如果程序出了错, 先怀疑自己的代码有bug. 比如由于你的疏忽, 你编写了if (p = NULL)这样的代码. 但执行到这行代码的时候, 也只是p被赋值成NULL, 程序还会往下执行. 然而等到将来对p进行了解引用的时候, 才会触发段错误, 程序彻底崩溃.

我们可以从上面的这个例子中抽象出一些软件工程相关的概念:

  • Fault: 实现错误的代码, 例如if (p = NULL)
  • Error: 程序执行时不符合预期的状态, 例如p被错误地赋值成NULL
  • Failure: 能直接观测到的错误, 例如程序触发了段错误

调试其实就是从观测到的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

如果你遇到了段错误, 你很可能会想知道究竟是哪一行代码触发了段错误. 尝试编写一个触发段错误的程序, 然后在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理解程序的精确行为

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

断点

断点的功能是让程序暂停下来, 从而方便查看程序某一时刻的状态. 事实上, 我们可以很容易地用监视点来模拟断点的功能:

w $pc == ADDR

其中ADDR为设置断点的地址. 这样程序执行到ADDR的位置时就会暂停下来.

如何提高断点的效率 (建议二周目思考)

如果你在运行稍大一些的程序(如microbench)的时候使用断点, 你会发现设置断点之后会明显地降低NEMU执行程序的效率. 思考一下这是为什么? 有什么方法解决这个问题吗?

调试器设置断点的工作方式和上述通过监视点来模拟断点的方法大相径庭. 事实上, 断点的工作原理, 竟然是三十六计之中的"偷龙转凤"! 如果你想揭开这一神秘的面纱, 你可以阅读这篇文章. 了解断点的工作原理之后, 可以尝试思考下面的两个问题.

一点也不能长?

x86的int3指令不带任何操作数, 操作码为1个字节, 因此指令的长度是1个字节. 这是必须的吗? 假设有一种x86体系结构的变种my-x86, 除了int3指令的长度变成了2个字节之外, 其余指令和x86相同. 在my-x86中, 上述文章中的断点机制还可以正常工作吗? 为什么?

随心所欲的断点

如果把断点设置在指令的非首字节(中间或末尾), 会发生什么? 你可以在GDB中尝试一下, 然后思考并解释其中的缘由.

NEMU的前世今生

你已经对NEMU的工作方式有所了解了. 事实上在NEMU诞生之前, NEMU曾经有一段时间并不叫NEMU, 而是叫NDB(NJU Debugger), 后来由于某种原因才改名为NEMU. 如果你想知道这一段史前的秘密, 你首先需要了解这样一个问题: 模拟器(Emulator)和调试器(Debugger)有什么不同? 更具体地, 和NEMU相比, GDB到底是如何调试程序的?

results matching ""

    No results matching ""