监视点

你应在本节中完成以下任务

  1. 在 GDB 中尝试使用监视点和断点功能;
  2. 阅读项目中和本节相关的源代码;
  3. 实现监视点相关功能;
  4. 回答讲义中的所有思考题;
  5. 回答章末思考题。

监视点

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

体验监视点

使用 GDB 对一个 helloworld 或你所编写的其他小程序进行调试,并使用 GDB 的监视点功能,如果你不知道如何使用,可以查阅 GDB 文档open in new window。你需要体验的功能如下:

  • 使用适当的 GDB 命令新建两个监视点,可以根据你的喜好监视任意表达式;
  • 使用适当的 GDB 命令显示当前所有监视点的列表;
  • 运行程序,使程序命中监视点至少一次(如果上一步你所设定的监视点没能命中,请你更换一个表达式);
  • 使用适当的 GDB 命令删除任意一个之前设置的监视点;
  • 不退出 GDB,重新运行程序,使程序不能在被删除的监视点上命中。

如果你不知道应该监视什么表达式,你仍然可以参考 GDB 文档open in new window中的例子。

上面的思考题令你对监视点功能有了最基本的感受,下面我们就来实现这些功能。简易调试器允许用户同时设置多个监视点、删除监视点,因此我们最好使用链表将监视点的信息组织起来。框架代码中已经定义好了监视点的结构体(在 nemu/include/monitor/watchpoint.h 中):

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

  /* TODO: Add more members if necessary */

} WP;

但结构体中只定义了两个成员:NO 表示监视点的序号,next 指向下一个结构体。为了实现监视点的功能,你需要根据你对监视点工作原理的理解在结构体中增加必要的成员。

任务1:实现监视点结构体

你需要在监视点结构体中增加如下几个数据成员:

  • expr:字符数组类型,用于存储被监视的表达式;
  • new_val:表达式的新值,当新值和旧值不匹配时,触发程序暂停运行;
  • old_val:表达式的旧值。

同时我们使用"池"的数据结构来管理监视点结构体,框架代码中已经给出了一部分相关的代码(在 nemu/src/monitor/debug/watchpoint.c 中):

static WP wp_pool[NR_WP];
static WP *head, *free_;

代码中定义了监视点结构的池 wp_pool,还有两个链表 headfree_,其中 head 用于组织使用中的监视点结构,free_ 用于组织空闲的监视点结构。init_wp_pool() 函数会对两个链表进行初始化。用一个图画来表示,大概可以表现成如下结构:

          head  NULL free_   +-----+     +-----+     +---...---+    NULL
           |     ^     |     |     |     |     |     |         |     ^
           v     |     v     |     v     |     v     |         v     |
        +-----+-----+-----+-----+-----+-----+-----+-----+   +-----+-----+
        |     |     |     |     |     |     |     |     |   |     |     |
wp_pool |  #  |  #  |  *  |  *  |  *  |  *  |  *  |  *  |...|  *  |  *  |
        |     |     |     |     |     |     |     |     |   |     |     |
        +-----+-----+-----+-----+-----+-----+-----+-----+   +-----+-----+
       [0] | [1] ^ [2] | [3] ^ [4] | [5] ^ [6] | [7] ^         |     ^ 
           |     |     |     |     |     |     |     |         |     |
           +-----+     +-----+     +-----+     +-----+         +-----+

你可以看到,这其实是一个静态链表,其中标注 # 的元素是使用中的监视点,标注 * 的元素是没有使用的监视点。初始化状态下,应该所有元素都是未使用的,此时 head 指针指向 NULL; 当所有监视点都在使用中时,free_ 指针应该指向 NULL

科学起名

上述的池管理中,对于空闲监视点的管理,使用 free_ 作为头指针,那此处可否使用 free 来作为头指针的名字呢?如果使用 free 作为头指针名,从语法上看起来好像没错,但是到编译运行的时候,还能一帆风顺吗?(hint: 符号名撞车)

任务2:实现监视点池的管理

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

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

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

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

温故而知新

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

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

  • 当用户使用 w 命令给出一个待监视表达式时,你需要通过 new_wp() 申请一个空闲的监视点结构,**并将表达式本身以及表达式的当前值(作为旧值)记录下来。**参考输出如下:

    (nemu) w $eax
    Set watchpoint #0
    expr      = $eax
    old value = 0x41a7c443
    (nemu) _
    
  • 每当 cpu_exec() 执行完一条指令,就对所有待监视的表达式进行求值(你之前已经实现表达式求值的功能了),比较它们的值有没有发生变化(即新值和旧值做比较),若发生了变化,程序就因触发了监视点而暂停下来,你需要将 nemu_state 变量设置为 NEMU_STOP 来达到暂停的效果,并用变化后的值覆盖旧值(即作为新的旧值)。最后输出一句话提示用户触发了监视点,并返回到 ui_mainloop() 循环中等待用户的命令。参考输出如下:

    (nemu) si 5
    Hit watchpoint 0 at address 0x00100000
    expr      = $eax
    old value = 0x41a7c443
    new value = 0x00001234
    program paused
    (nemu) _
    
  • 使用 info w 命令来打印使用中的监视点信息,至于要打印什么,你可以参考 GDB 中 info watchpoints 的运行结果。参考输出如下:

    (nemu) info w
    NO Expr         Old Value
    0  $eax         0x41a7c443
    1  $ecx == 1    0x00000000
    (nemu) _
    
  • 使用 d 命令来删除监视点,你只需要释放相应的监视点结构即可。参考输出如下:

    (nemu) d 0
    Watchpoint 0 deleted
    (nemu) _
    

任务3:将监视点加入调试器功能

你需要根据上文的描述在简易调试器中增加相应的命令和命令处理函数,包括:

  • w 命令:根据给予的表达式 expr 设置一个新的监视点,如 w $eax
  • d 命令:根据给予的监视点编号 NO 删除该监视点,如 d 1
  • info w 命令:显示当前所有监视点。

任务4:实现监视点

你需要实现上文描述的监视点相关功能,实现了表达式求值之后,监视点实现的重点就落在了链操作上。如果你仍然因为链表的实现而感到调试困难,请尝试学会使用 assertion。你需要在 watchpoint.c 中实现如下几个函数:

int set_watchpoint(char *e);    //给予一个表达式e,构造以该表达式为监视目标的监视点,并返回编号
bool delete_watchpoint(int NO); //给予一个监视点编号,从已使用的监视点中归还该监视点到池中
void list_watchpoint(void);     //显示当前在使用状态中的监视点列表
WP* scan_watchpoint(void);      //扫描所有使用中的监视点,返回触发的监视点指针,若无触发返回NULL

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

这里,我们仍然提供各个函数的逻辑供大家参考:

  • set_watchpoint:取得一个空监视点 -> 维护空闲链表和使用中链表 -> 存入表达式和旧值;
  • delete_watchpoint:遍历链表 -> 找到指定编号 -> 将废弃的监视点节点归还 -> 维护空闲链表和使用中链表;
  • list_watchpoint:遍历链表;
  • scan_watchpoint:遍历链表 -> 逐个监视点计算表达式新值 -> 比较新值和旧值 -> 根据情况作出处理。

断点

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

w $eip == ADDR

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

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

任务5:使用模拟断点

按照上文的描述,尝试使用类似的表达式实现用监视点模拟断点的功能。

选做任务:实现软件断点

int3 指令又称为软件断点指令,它的工作方式是通过将给定地址的指令码替换为 int3 指令的指令码 0xcc,你可尝试实现。当执行 int3 指令时,NEMU 将执行中断指令,停在当前 int3 指令所处位置。动态设置和恢复断点指令的逻辑如下:

  • 程序开始运行时(c 命令或 si x 命令),根据当前断点列表中所存储信息,将列表中的地址上的指令码均替换为 0xcc,并保存该地址上实际字节;
  • 程序命中 0xcc 指令码时,NEMU 执行 int3 的指令处理函数(这里需要自行仿照 mov 指令的执行流程,在相应代码文件(hint: system.c)中编写 int3 的处理函数,并填写指令表,这部分是 PA2 的内容,可能有一定难度),处理函数的逻辑为:将原有指令替换回所有被 int3 所占据的位置,并设置 nemu_state 变量为 NEMU_STOP

提示:你可以复用上面的监视点池,为其增加相应的控制字段(如类型字段、断点地址字段和被替换字节内容等)来实现。

为了检测你的运行是否正常,你还需要向简易调试器增加 b 命令作为软件断点指令,命令格式如 b 0x100006 等。删除软件断点命令亦使用 d 命令,软件断点可以与监视点统一编号。

一点也不能长?

我们知道 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 到底是如何调试程序的?

i386 手册

在以后的PA中, 你需要反复阅读i386手册. 鉴于有同学片面地认为"看手册"就是"把手册全看一遍", 因而觉得"不可能在短时间内看完", 我们在PA1的最后来聊聊如何科学地看手册.

学会使用目录

了解一本书都有哪些内容的最快方法就是查看目录, 尤其是当你第一次看一本新书的时候. 查看目录之后并不代表你知道它们具体在说什么, 但你会对这些内容有一个初步的印象, 提到某一个概念的时候, 你可以大概知道这个概念会在手册中的哪些章节出现. 这对查阅手册来说是极其重要的, 因为我们每次查阅手册的时候总是关注某一个问题, 如果每次都需要把手册从头到尾都看一遍才能确定关注的问题在哪里, 效率是十分低下的. 事实上也没有人会这么做, 阅读目录的重要性可见一斑. 纸上得来终觉浅, 还是来动手体会一下吧!

尝试通过目录定位关注的问题

假设你现在需要了解一个叫selector的概念, 请通过i386手册的目录确定你需要阅读手册中的哪些地方.

怎么样, 是不是很简单? 虽然你还是不明白selector是什么, 但你已经知道你需要阅读哪些地方了, 要弄明白selector, 那也是指日可待的事情了.

逐步细化搜索范围

有时候你关注的问题不一定直接能在目录里面找到, 例如"CR0寄存器的PG位的含义是什么". 这种细节的问题一般都是出现在正文中, 而不会直接出现在目录中, 因此你就不能直接通过目录来定位相应的内容了. 根据你是否第一次接触CR0, 查阅这个问题会有不同的方法:

  • 如果你已经知道CR0是个control register, 你可以直接在目录里面查看"control register"所在的章节, 然后在这些章节的正文中寻找"CR0".
  • 如果你对CR0一无所知, 你可以使用阅读器中的搜索功能, 搜索"CR0", 还是可以很快地找到"CR0"的相关内容. 不过最好的方法是首先使用搜索引擎, 你可以马上知道"CR0是个control register", 然后就可以像第一种方法那样查阅手册了.

不过有时候, 你会发现一个概念在手册中的多个地方都有提到. 这时你需要明确你要关心概念的哪个方面, 通常一个概念的某个方面只会在手册中的一个地方进行详细的介绍. 你需要在这多个地方中进行进一步的筛选, 但至少你已经过滤掉很多与这个概念无关的章节了. 筛选也是有策略的, 你不需要把多个地方的所有内容全部阅读一遍才能进行筛选, 小标题, 每段的第一句话, 图表的注解, 这些都可以帮助你很快地了解这一部分的内容大概在讲什么. 这不就是高中英语考试中的快速阅读吗? 对的, 就是这样. 如果你觉得目前还缺乏这方面的能力, 现在锻炼的好机会来了.

搜索和筛选信息是一个trail and error的过程, 没有什么方法能够指导你在第一遍搜索就能成功, 但还是有经验可言的. 搜索失败的时候, 你应该尝试使用不同的关键字重新搜索. 至于怎么变换关键字, 就要看你对问题核心的理解了, 换句话说, 怎么问才算是切中要害. 这不就是高中语文强调的表达能力吗? 对的, 就是这样.

事实上, 你只需要具备一些基本的交际能力, 就能学会查阅资料, 和资料的内容没有关系, 来一本"民法大全", "XX手机使用说明书", "YY公司人员管理记录", 照样是这么查阅. "查阅资料"是一种与领域无关的基本能力, 无论身处哪一个行业都需要具备, 如果你不想以后工作的时候被查阅资料的能力影响了自己的前途, 从现在开始就努力锻炼吧!

手册勘误

你从 i386 手册的封面就能够得知它来自于哪个年代。由于年久失修,手册中总是存在许多错误,这里我们整理了一份手册勘误,当你对手册内容抱有疑惑时,不妨先阅读手册勘误


PA1 的最后,还有几个思考题,请你回答并写在报告中。

理解基础设施

我们通过一些简单的计算来体会简易调试器的作用. 首先作以下假设:

  • 假设你需要编译500次NEMU才能完成PA.
  • 假设这500次编译当中, 有90%的次数是用于调试.
  • 假设你没有实现简易调试器, 只能通过GDB对运行在NEMU上的客户程序进行调试. 在每一次调试中, 由于GDB不能直接观测客户程序, 你需要花费30秒的时间来从GDB中获取并分析一个信息.
  • 假设你需要获取并分析20个信息才能排除一个bug.

那么这个学期下来, 你将会在调试上花费多少时间?

由于简易调试器可以直接观测客户程序, 假设通过简易调试器只需要花费10秒的时间从中获取并分析相同的信息. 那么这个学期下来, 简易调试器可以帮助你节省多少调试的时间?

事实上, 这些数字也许还是有点乐观, 例如就算使用GDB来直接调客户程序, 这些数字假设你能通过10分钟的时间排除一个bug. 如果实际上你需要在调试过程中获取并分析更多的信息, 简易调试器这一基础设施能带来的好处就更大.

查阅i386手册

理解了科学查阅手册的方法之后, 请你尝试在i386手册中查阅以下问题所在的位置, 把需要阅读的范围写到你的实验报告里面:

  • EFLAGS寄存器中的CF位是什么意思?
  • ModR/M字节是什么?
  • mov指令的具体格式是怎么样的?

shell命令

完成PA1的内容之后, nemu/目录下的所有.c和.h和文件总共有多少行代码? 你是使用什么命令得到这个结果的? 和框架代码相比, 你在PA1中编写了多少行代码? (Hint: 目前master分支中记录的正好是做PA1之前的状态, 思考一下应该如何回到"过去"?) 你可以把这条命令写入Makefile中, 随着实验进度的推进, 你可以很方便地统计工程的代码行数, 例如敲入make count就会自动运行统计代码行数的命令. 再来个难一点的, 除去空行之外, nemu/目录下的所有.c.h文件总共有多少行代码?

使用man

打开工程目录下的Makefile文件, 你会在CFLAGS变量中看到gcc的一些编译选项. 请解释gcc中的-Wall-Werror有什么作用? 为什么要使用-Wall-Werror?


以上是 PA1.3 的所有内容。PA1 的所有内容也到此为止。

Last Updated 2023-04-02 14:55:00