第三视点: 简易调试器(2)

接下来, 我们将会对简易调试器的功能进行扩展, 为调试提供更多的手段.

运行用户程序add

在继续之前, 请保证用户程序add可以在NEMU中正确运行. 你将使用add程序来测试简易调试器的新功能.

添加变量支持

你已经在PA1中实现了简易调试器, 现在你已经将用户程序换成了C程序. 和之前的 mov.S 相比, C程序多了变量和函数的要素, 那么在表达式求值中如何支持变量的输出呢?

(nemu) p test_data

换句话说, 我们怎么从 test_data 这个字符串找到这个变量在运行时刻的信息? 下面我们就来讨论这个问题.

符号表(symbol table)是可执行文件的一个section, 它记录了程序编译时刻的一些信息, 其中就包括变量和函数的信息. 为了完善调试器的功能, 我们首先需要了解符号表中都记录了哪些信息.

以add这个用户程序为例, 使用 readelf 命令查看ELF可执行文件的信息:

readelf -a add

你会看到 readelf 命令输出了很多信息, 这些信息对了解ELF的结构有很好的帮助, 我们建议你在课后仔细琢磨. 目前我们只需要关心符号表的信息就可以了, 在输出中找到符号表的信息:

Symbol table '.symtab' contains 10 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00100000     0 SECTION LOCAL  DEFAULT    1 
     2: 0010009c     0 SECTION LOCAL  DEFAULT    2 
     3: 00100100     0 SECTION LOCAL  DEFAULT    3 
     4: 00000000     0 SECTION LOCAL  DEFAULT    4 
     5: 00000000     0 FILE    LOCAL  DEFAULT  ABS add.c
     6: 00100084    22 FUNC    GLOBAL DEFAULT    1 add
     7: 00100000   129 FUNC    GLOBAL DEFAULT    1 main
     8: 00100120   256 OBJECT  GLOBAL DEFAULT    3 ans
     9: 00100100    32 OBJECT  GLOBAL DEFAULT    3 test_data

其中每一行代表一个表项, 每一列列出了表项的一些属性, 现在我们只需要关心 Type 属性为 OBJECT 的表项就可以了. 仔细观察 Name 属性之后, 你会发现这些表项正好对应了 add.c 中定义的全局变量, 而相应的 Value 属性正好是它们的地址(你可以与 add.txt 中的反汇编结果进行对比), 而找到地址之后就可以找到这个变量了.

消失的符号

我们在 add.c 中定义了宏 NR_DATA , 同时也在 add() 函数中定义了局部变量 c 和形参 a , b , 但你会发现在符号表中找不到和它们对应的表项, 为什么会这样? 思考一下, 什么才算是一个符号(symbol)?

太好了, 我们可以通过符号表建立变量名和其地址之间的映射关系! 别着急, readelf 输出的信息是已经经过解析的, 实际上符号表中 Name 属性存放的是字符串在字符串表(string table)中的偏移量. 为了查看字符串表, 我们先查看 readelf 输出中Section Headers的信息:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .text             PROGBITS        00100000 001000 00009a 00  AX  0   0  4
  [ 2] .eh_frame         PROGBITS        0010009c 00109c 000058 00   A  0   0  4
  [ 3] .data             PROGBITS        00100100 001100 000120 00  WA  0   0 32
  [ 4] .comment          PROGBITS        00000000 001220 00001c 01  MS  0   0  1
  [ 5] .shstrtab         STRTAB          00000000 00123c 00003a 00      0   0  1
  [ 6] .symtab           SYMTAB          00000000 0013b8 0000a0 10      7   6  4
  [ 7] .strtab           STRTAB          00000000 001458 00001e 00      0   0  1

从Section Headers的信息可以看到, 字符串表在ELF文件偏移为 0x1458 的位置开始存放. 在shell中可以通过以下命令直接输出ELF文件的十六进制形式:

hd add

查看输出结果的最后几行, 我们可以看到, 字符串表只不过是把标识符的字符串拼接起来而已. 现在我们就可以厘清符号表和字符串表之间的关系了:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 7] .strtab           STRTAB          00000000 001458 00001e 00      0   0  1
                                                  |                                 
                                   +--------------+     +-----------------------+   
                                   V                    V                       |   
00001450  20 00 00 00 11 00 03 00  00 61 64 64 2e 63 00 61  | ........add.c.a|  |
00001460  64 64 00 6d 61 69 6e 00  61 6e 73 00 74 65 73 74  |dd.main.ans.test|  |
00001470  5f 64 61 74 61 00        ^           ^            |_data.|            |
                                   |           |                                |   
                                   |           +-------------+                  |   
                                   |                         |                  |   
                                   +---------------------+   |                  |   
Symbol table '.symtab' contains 10 entries:              |   |                  |
   Num:    Value  Size Type    Bind   Vis      Ndx Name  |   |                  |
     5: 00000000     0 FILE    LOCAL  DEFAULT  ABS 1     |   |                  |
     6: 00100084    22 FUNC    GLOBAL DEFAULT    1 7  ---+---+------------------+
     7: 00100000   129 FUNC    GLOBAL DEFAULT    1 11    |   |
     8: 00100120   256 OBJECT  GLOBAL DEFAULT    3 16 ---+   |
     9: 00100100    32 OBJECT  GLOBAL DEFAULT    3 20 -------+

寻找"Hello World!"

在GNU/Linux下编写一个Hello World程序, 编译后通过上述方法找到ELF文件的字符串表, 你发现"Hello World!"字符串在字符串表中的什么位置? 为什么会这样?

一种解决方法已经呼之欲出了: 在表达式递归求值的过程中, 如果发现token的类型是一个标识符, 就通过这个标识符在符号表中找到一项符合要求的表项(表项的 Type 属性是 OBJECT , 并且将 Name 属性的值作为字符串表中的偏移所找到的字符串和标识符的命名一致), 找到标识符的地址, 并将这个地址作为结果返回. 在上述add程序的例子中:

(nemu) p test_data
0x100100

需要注意的是, 如果标识符是一个基本类型变量, 简易调试器和GDB的处理会有所不同: 在GDB中会直接返回基本类型变量的值, 但我们在表达式求值中并没有实现类型系统, 因此我们无法区分一个标识符是否基本类型变量, 所以我们统一输出变量的地址. 如果对于一个整型变量 x , 我们可以通过以下方式输出它的值:

(nemu) p *x

而对于一个整型数组 A , 如果想输出 A[1] 的值, 可以通过以下方式:

(nemu) p *(A + 4)

为表达式求值添加变量的支持

根据上文提到的方法, 向表达式求值添加变量的支持, 为此, 你还需要在表达式求值的词法分析和递归求值中添加对变量的识别和处理. 框架代码提供的 load_table() 函数已经为你从可执行文件中抽取出符号表和字符串表了, 其中 strtab 是字符串表, symtab 是符号表, nr_symtab_entry 是符号表的表项数目, 更多的信息请阅读 nemu/src/monitor/debug/elf.c .

头文件 <elf.h> 已经为我们定义了与ELF可执行文件相关的数据结构, 为了使用符号表, 请查阅

man 5 elf

实现之后, 你就可以在表达式中使用变量了. 在NEMU中运行add程序, 并打印全局数组某些元素的值.

丢失的信息

在用户程序中定义以下字符数组:

char str[] = "abcdefg";

尝试通过上述方式输出 str[1] 的值, 你发现有什么问题? 运用现有的信息, 你能够解决这个问题吗? 如果能, 请描述解决方法, 并尝试实现; 如果不能, 请解释为什么, 并尝试总结这背后反映的事实.

冗余的符号表

在GNU/Linux下编写一个Hello World程序, 然后使用 strip 命令丢弃可执行文件中的符号表:

gcc -o hello hello.c
strip -s hello

readelf 查看hello的信息, 你会发现符号表被丢弃了, 此时的hello程序能成功运行吗?

目标文件中也有符号表, 我们同样可以丢弃它:

gcc -c hello.c
strip -s hello.o

readelf 查看hello.o的信息, 你会发现符号表被丢弃了. 尝试对hello.o进行链接:

gcc -o hello hello.o

你发现了什么问题? 尝试对比上述两种情况, 并分析其中的原因.

打印栈帧链

我们知道函数调用会在堆栈上形成栈帧, 记录和这次函数调用有关的信息. 若干次连续的函数调用将会在堆栈上形成一条栈帧链, 为调试提供了很多有用的信息: %eip 可以让你知道程序现在的位置, 栈帧链则可以告诉你, 程序是怎么运行到现在的位置的. 我们需要在简易调试器中添加 bt 命令, 打印出栈帧链的信息, 如果你从来没有使用过 bt 命令, 请先在GDB中尝试.

在堆栈中形成的栈帧链结构如下:

      |   |   ......   | 4G
    stack +------------+ 
    frame |  prev_ebp  |
      |   +------------+ <--+
      |   | local_var  |    |
      v   | &temp_var  |    |
    ----- +------------+    |
      ^   | arguments  |    |
      |   +------------+    |
      |   |  ret_addr  |    |
    stack +------------+    |
    frame |  prev_ebp  | ---+
      |   +------------+ <--+
      |   | local_var  |    |
      v   | &temp_var  |    |
    ----- +------------+    |
      ^   | arguments  |    |
      |   +------------+    |
      |   |  ret_addr  |    |
    stack +------------+    |
    frame |  prev_ebp  | ---+
      |   +------------+ <--+
      |   | local_var  |    |
      v   | &temp_var  |    |
    ----- +------------+    |
      ^   | arguments  |    |
      |   +------------+    |
      |   |  ret_addr  |    |
    stack +------------+    |
    frame |  prev_ebp  | ---+
      |   +------------+ <-- %ebp
      |   |   ......   | 0

可以看到, %ebp 在栈帧链的组织中起到了非常重要的作用, 通过 %ebp , 我们就可以找到每一个栈帧的信息了. 聪明的你也许一眼就看出来, 这不就是程序设计课中学过的链表吗? 我们可以定义一个结构体来进一步厘清其中的奥妙:

typedef struct { swaddr_t prev_ebp; swaddr_t ret_addr; uint32_t args[4]; } PartOfStackFrame;

其中 prev_ebp 就类似于 next 指针, 不过我们没有将它定义成指针类型, 这是因为它表示的地址是用户程序的地址, 直接把它作为NEMU的地址来进行解引用就会发生错误, 所以这个结构体中的每一个成员都需要通过 swaddr_read() 来读取. args 成员数组表示函数的实参, 实际上实参的个数不一定是4个, 但我们仍然可以将它们强制打印出来, 说不定可以从中发现一些有用的调试信息. 链表的表头存储在 %ebp 寄存器中, 所以我们可以从 %ebp 寄存器开始, 像遍历链表那样逐一扫描并打印栈帧链中的信息. 链表通过 NULL 指示链表的结束, 在栈帧链中也是类似的. 还记得NEMU提供的运行时环境吗? %ebp 寄存器的初值为 0 , 当我们发现栈帧中 %ebp 的信息为 0 时, 就表示已经达到最开始运行的函数了.

由于缺乏形参和局部变量的具体信息, 我们只需要打印地址, 函数名, 以及前4个参数就可以了, 打印格式可以参考GDB中 bt 命令的输出. 如何确定某个地址落在哪一个函数中呢? 这就需要符号表的帮助了:

Symbol table '.symtab' contains 10 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00100000     0 SECTION LOCAL  DEFAULT    1 
     2: 0010009c     0 SECTION LOCAL  DEFAULT    2 
     3: 00100100     0 SECTION LOCAL  DEFAULT    3 
     4: 00000000     0 SECTION LOCAL  DEFAULT    4 
     5: 00000000     0 FILE    LOCAL  DEFAULT  ABS add.c
     6: 00100084    22 FUNC    GLOBAL DEFAULT    1 add
     7: 00100000   129 FUNC    GLOBAL DEFAULT    1 main
     8: 00100120   256 OBJECT  GLOBAL DEFAULT    3 ans
     9: 00100100    32 OBJECT  GLOBAL DEFAULT    3 test_data

对于 Type 属性为 FUNC 的表项, Value 属性指示了函数的起始地址, Size 属性指示了函数的大小, 通过这两个属性就可以确定函数的范围了. 由于函数的范围是互不相交的, 因此我们可以通过扫描符号表中 Type 属性为 FUNC 的每一个表项, 唯一确定一个地址在所的函数. 为了得到函数名, 你只需要根据表项中的 Name 属性在字符串表中找到相应的字符串就可以了.

打印栈帧链

为简易调试器添加 bt 命令, 实现打印栈帧链的功能. 实现之后, 在add的 add() 函数中设置断点, 触发断点之后, 在monitor中测试 bt 命令的实现是否正确.

%ebp是必须的吗?

使用优化选项编译代码的时候, gcc会对代码进行优化, 会将 %ebp 当作普通的寄存器来使用, 不再让其作为指示当前的栈帧, 更多的信息可以查阅 man gcc 中的 -fomit-frame-pointer 选项. 我们使用 -O2 来编译NEMU, 你可以对NEMU进行反汇编, 查看一些函数的代码. 在这种情况下, 代码要怎么找到函数调用的参数和局部变量?

另外优化 %ebp 寄存器之后, 就不能使用上述方法来打印栈帧链了. 如果你使用GDB对NEMU进行调试, 你会发现仍然可以使用bt命令来打印栈帧链. 你知道这是怎么做到的吗? 在优化 %ebp 寄存器之后, 为了打印栈帧链, 还需要哪些信息?

温馨提示

PA2阶段3到此结束.

results matching ""

    No results matching ""