Heim  >  Artikel  >  System-Tutorial  >  Analyse des Funktionsprinzips des Debuggers (3): Eingehende Untersuchung der Debugging-Informationen

Analyse des Funktionsprinzips des Debuggers (3): Eingehende Untersuchung der Debugging-Informationen

WBOY
WBOYnach vorne
2024-01-01 20:05:221339Durchsuche
Einführung In diesem Artikel wird erklärt, wie der Debugger im Maschinencode nach den C-Sprachfunktionen, -Variablen und -Daten sucht, die er benötigt, um den C-Sprachquellcode in Maschinensprachencode umzuwandeln.

Dies ist der dritte Artikel in der Reihe über die Funktionsweise von Debuggern. Sie sollten Teil 1 und 2 lesen, bevor Sie diesen Artikel lesen.

Debug-Informationen

Moderne Compiler können Hochsprachencodes mit verschiedenen eingerückten oder verschachtelten Programmabläufen und Variablen verschiedener Datentypen in viele 0/1-Daten umwandeln, die als Maschinencode bezeichnet werden. Der einzige Zweck besteht darin, das Programm auf der Ziel-CPU auszuführen so schnell wie möglich. Im Allgemeinen kann eine Zeile C-Sprachcode in mehrere Zeilen Maschinencode umgewandelt werden. Variablen sind über verschiedene Teile des Maschinencodes verteilt, einige auf dem Stapel, andere in Registern oder direkt wegoptimiert. Datenstrukturen und Objekte „existieren“ nicht einmal im Maschinencode. Sie werden lediglich dazu verwendet, Daten gemäß einer bestimmten strukturellen Kodierung im Cache zu speichern.

Woher weiß der Debugger also, wo das Programm anhalten soll, wenn Sie beim Eintritt in eine bestimmte Funktion eine Pause einlegen müssen? Woher weiß es, dass es beim Betrachten eines Variablenwerts diesen Wert findet? Die Antwort lautet: Debugging-Informationen.

Der Compiler generiert beim Generieren von Maschinencode auch entsprechende Debugging-Informationen. Debugging-Informationen stellen die Beziehung zwischen dem ausführbaren Programm und dem Quellcode dar und werden zusammen mit dem Maschinencode in einem vorab definierten Format gespeichert. Im Laufe der Jahre wurden viele Formate zum Speichern dieser Informationen für verschiedene Plattformen und ausführbare Dateien erfunden. In diesem Artikel wird jedoch nicht auf die Geschichte dieser Formate eingegangen, sondern vielmehr auf die Funktionsweise dieser Debugging-Informationen. Daher konzentrieren wir uns auf etwas wie DWARF. DWARF wird heute häufig als Debugging-Format für ausführbare Dateien auf Linux- und Unix-ähnlichen Plattformen verwendet.

ZWERG in ELF

Analyse des Funktionsprinzips des Debuggers (3): Eingehende Untersuchung der Debugging-Informationen

Obwohl DWARF zusammen mit ELF entwickelt wurde (DWARF ist ein offener Standard, der vom DWARF Standards Committee ins Leben gerufen wurde. Das oben gezeigte Symbol stammt von dieser Website), kann DWARF theoretisch auch in andere ausführbare Dateien eingebettet werden Formate.

DWARF ist ein komplexes Format, das auf langjähriger Erfahrung mit verschiedenen Architekturen und Betriebssystemformaten basiert. Gerade weil es das hartnäckige Problem der Generierung von Debug-Informationen für jede Hochsprache auf jeder Plattform und ABI (Application Binary Interface) löst, muss es auch komplex sein. Wenn Sie DWARF gründlich erklären möchten, reicht es nicht aus, nur diesen dünnen Artikel durchzugehen. Ehrlich gesagt verstehe ich DWARF nicht bis ins kleinste Detail, daher kann ich es nicht sehr gründlich erklären (wenn Sie das Gefühl haben). Wenn Sie interessiert sind, finden Sie am Ende des Artikels einige Ressourcen, die Ihnen helfen können. Es wird empfohlen, mit dem DWARF-Tutorial zu beginnen. In diesem Artikel werde ich DWARF auf leicht verständliche Weise vorstellen, um zu veranschaulichen, wie Debugging-Informationen tatsächlich funktionieren.

Debug-Abschnitt in der ELF-Datei

Lass uns zunächst sehen, wo sich DWARF in der ELF-Datei befindet. ELF

Unsere Experimente in diesem Artikel verwenden die ausführbare Datei, die aus dieser C-Sprachquelldatei erstellt und in Tracedprog2 kompiliert wurde:

#include <stdio.h>

void do_stuff(int my_arg)、
{
    int my_local = my_arg + 2;
    int i;

    for (i = 0; i 
<p>Verwenden Sie den Befehl objdump -h, um den <ruby>Abschnittsheader<rt></rt></ruby></p> in der ausführbaren ELF-Datei zu überprüfen. Wir werden mehrere Abschnitte sehen, die mit .debug_ beginnen. Dies sind die Debugging-Teile von DWARF. <p>
</p>
<pre class="brush:php;toolbar:false">26 .debug_aranges 00000020  00000000  00000000  00001037
                 CONTENTS, READONLY, DEBUGGING
27 .debug_pubnames 00000028  00000000  00000000  00001057
                 CONTENTS, READONLY, DEBUGGING
28 .debug_info   000000cc  00000000  00000000  0000107f
                 CONTENTS, READONLY, DEBUGGING
29 .debug_abbrev 0000008a  00000000  00000000  0000114b
                 CONTENTS, READONLY, DEBUGGING
30 .debug_line   0000006b  00000000  00000000  000011d5
                 CONTENTS, READONLY, DEBUGGING
31 .debug_frame  00000044  00000000  00000000  00001240
                 CONTENTS, READONLY, DEBUGGING
32 .debug_str    000000ae  00000000  00000000  00001284
                 CONTENTS, READONLY, DEBUGGING
33 .debug_loc    00000058  00000000  00000000  00001332
                 CONTENTS, READONLY, DEBUGGING
Die erste Zahl jedes Abschnitts stellt die Größe des Abschnitts dar und die letzte Zahl stellt den Versatz der Startposition dieses Abschnitts von ELF dar. Der Debugger verwendet diese Informationen, um Abschnitte aus der ausführbaren Datei zu lesen.

Schauen wir uns nun einige praktische Beispiele für die Suche nach nützlichen Debugging-Informationen in DWARF an. Suchfunktion

Eine der grundlegendsten Aufgaben des Debuggers besteht darin, dass der Debugger beim Festlegen eines Haltepunkts an einer bestimmten Funktion in der Lage sein muss, am Einstiegspunkt anzuhalten. Dazu muss eine Zuordnung zwischen dem Funktionsnamen im High-Level-Code und der Adresse, an der die Funktionsanweisung im Maschinencode beginnt, hergestellt werden.

Um diese Zuordnungsbeziehung zu erhalten, können wir in DWARF nach dem Abschnitt .debug_info suchen. Bevor wir eintauchen, sind ein wenig Grundkenntnisse erforderlich. Jeder Beschreibungstyp in DWARF wird als Debug Information Entry (DIE) bezeichnet. Jeder DIE verfügt über Tags zu seinem Typ, seinen Eigenschaften usw. DIEs sind über Geschwisterknoten oder untergeordnete Knoten miteinander verbunden, und die Werte von Attributen können auch auf andere DIEs verweisen.

Führen Sie den folgenden Befehl aus:

objdump --dwarf=info tracedprog2
Der Einfachheit halber konzentrieren wir uns nur auf diese Zeilen (ab hier werde ich unnötige lange Informationen durch (...) ersetzen): 🎜
: Abbrev Number: 5 (DW_TAG_subprogram)
       DW_AT_external    : 1
       DW_AT_name        : (...): do_stuff
       DW_AT_decl_file   : 1
       DW_AT_decl_line   : 4
       DW_AT_prototyped  : 1
       DW_AT_low_pc      : 0x8048604
       DW_AT_high_pc     : 0x804863e
       DW_AT_frame_base  : 0x0      (location list)
       DW_AT_sibling     : 

<b3>: Abbrev Number: 9 (DW_TAG_subprogram)
    <b4>   DW_AT_external    : 1
    <b5>   DW_AT_name        : (...): main
    <b9>   DW_AT_decl_file   : 1
    <ba>   DW_AT_decl_line   : 14
    <bb>   DW_AT_type        : 
    <bf>   DW_AT_low_pc      : 0x804863e
    <c3>   DW_AT_high_pc     : 0x804865a
    <c7>   DW_AT_frame_base  : 0x2c     (location list)
</c7></c3></bf></bb></ba></b9></b5></b4></b3>

上面的代码中有两个带有 DW_TAG_subprogram 标签的入口,在 DWARF 中这是对函数的指代。注意,这是两个节的入口,其中一个是 do_stuff 函数的入口,另一个是主(main)函数的入口。这些信息中有很多值得关注的属性,但其中最值得注意的是 DW_AT_low_pc。它代表了函数开始处程序指针的值(在 x86 平台上是 EIP)。此处 0x8048604 代表了 do_stuff 函数开始处的程序指针。下面我们将利用 objdump -d 命令对可执行文件进行反汇编。来看看这块地址中都有什么:

08048604 <do_stuff>:
 8048604:       55           push   ebp
 8048605:       89 e5        mov    ebp,esp
 8048607:       83 ec 28     sub    esp,0x28
 804860a:       8b 45 08     mov    eax,DWORD PTR [ebp+0x8]
 804860d:       83 c0 02     add    eax,0x2
 8048610:       89 45 f4     mov    DWORD PTR [ebp-0xc],eax
 8048613:       c7 45 (...)  mov    DWORD PTR [ebp-0x10],0x0
 804861a:       eb 18        jmp    8048634 <do_stuff>
 804861c:       b8 20 (...)  mov    eax,0x8048720
 8048621:       8b 55 f0     mov    edx,DWORD PTR [ebp-0x10]
 8048624:       89 54 24 04  mov    DWORD PTR [esp+0x4],edx
 8048628:       89 04 24     mov    DWORD PTR [esp],eax
 804862b:       e8 04 (...)  call   8048534 <printf>
 8048630:       83 45 f0 01  add    DWORD PTR [ebp-0x10],0x1
 8048634:       8b 45 f0     mov    eax,DWORD PTR [ebp-0x10]
 8048637:       3b 45 f4     cmp    eax,DWORD PTR [ebp-0xc]
 804863a:       7c e0        jl     804861c <do_stuff>
 804863c:       c9           leave
 804863d:       c3           ret
</do_stuff></printf></do_stuff></do_stuff>

显然,0x8048604 是 do_stuff 的开始地址,这样一来,调试器就可以建立函数与其在可执行文件中的位置间的映射关系。

查找变量

假设我们当前在 do_staff 函数中某个位置上设置断点停了下来。我们想通过调试器取得 my_local 这个变量的值。调试器怎么知道在哪里去找这个值呢?很显然这要比查找函数更为困难。变量可能存储在全局存储区、堆栈、甚至是寄存器中。此外,同名变量在不同的作用域中可能有着不同的值。调试信息必须能够反映所有的这些变化,当然,DWARF 就能做到。

我不会逐一去将每一种可能的状况,但我会以调试器在 do_stuff 函数中查找 my_local 变量的过程来举个例子。下面我们再看一遍 .debug_info 中 do_stuff 的每一个入口,这次连它的子入口也要一起看。

: Abbrev Number: 5 (DW_TAG_subprogram)
       DW_AT_external    : 1
       DW_AT_name        : (...): do_stuff
       DW_AT_decl_file   : 1
       DW_AT_decl_line   : 4
       DW_AT_prototyped  : 1
       DW_AT_low_pc      : 0x8048604
       DW_AT_high_pc     : 0x804863e
       DW_AT_frame_base  : 0x0      (location list)
       DW_AT_sibling     : 
 : Abbrev Number: 6 (DW_TAG_formal_parameter)
       DW_AT_name        : (...): my_arg
       DW_AT_decl_file   : 1
       DW_AT_decl_line   : 4
       DW_AT_type        : 
       DW_AT_location    : (...)       (DW_OP_fbreg: 0)
 : Abbrev Number: 7 (DW_TAG_variable)
       DW_AT_name        : (...): my_local
       DW_AT_decl_file   : 1
       DW_AT_decl_line   : 6
       DW_AT_type        : 
    <a3>   DW_AT_location    : (...)      (DW_OP_fbreg: -20)
<a6>: Abbrev Number: 8 (DW_TAG_variable)
    <a7>   DW_AT_name        : i
    <a9>   DW_AT_decl_file   : 1
    <aa>   DW_AT_decl_line   : 7
    <ab>   DW_AT_type        : 
    <af>   DW_AT_location    : (...)      (DW_OP_fbreg: -24)
</af></ab></aa></a9></a7></a6></a3>

看到每个入口处第一对尖括号中的数字了吗?这些是嵌套的等级,在上面的例子中,以 开头的入口是以 开头的子入口。因此我们得知 my_local 变量(以 DW_TAG_variable 标签标记)是 do_stuff 函数的局部变量。除此之外,调试器也需要知道变量的数据类型,这样才能正确的使用与显示变量。上面的例子中 my_local 的变量类型指向另一个 DIE 。如果使用 objdump 命令查看这个 DIE 的话,我们会发现它是一个有符号 4 字节整型数据。

而为了在实际运行的程序内存中查找变量的值,调试器需要使用到 DW_AT_location 属性。对于 my_local 而言,是 DW_OP_fbreg: -20。这个代码段的意思是说 my_local 存储在距离它所在函数起始地址偏移量为 -20 的地方。

do_stuff 函数的 DW_AT_frame_base 属性值为 0x0 (location list)。这意味着这个属性的值需要在 location list 中查找。下面我们来一起看看。

$ objdump --dwarf=loc tracedprog2

tracedprog2:     file format elf32-i386

Contents of the .debug_loc section:

    Offset   Begin    End      Expression
    00000000 08048604 08048605 (DW_OP_breg4: 4 )
    00000000 08048605 08048607 (DW_OP_breg4: 8 )
    00000000 08048607 0804863e (DW_OP_breg5: 8 )
    00000000 <end of list>
    0000002c 0804863e 0804863f (DW_OP_breg4: 4 )
    0000002c 0804863f 08048641 (DW_OP_breg4: 8 )
    0000002c 08048641 0804865a (DW_OP_breg5: 8 )
    0000002c <end of list>
</end></end>

我们需要关注的是第一列(do_stuff 函数的 DW_AT_frame_base 属性包含 location list 中 0x0 的偏移量。而 main 函数的相同属性包含 0x2c 的偏移量,这个偏移量是第二套地址列表的偏移量)。对于调试器可能定位到的每一个地址,它都会指定当前栈帧到变量间的偏移量,而这个偏移就是通过寄存器来计算的。对于 x86 平台而言,bpreg4 指向 esp,而 bpreg5 指向 ebp。

让我们再看看 do_stuff 函数的头几条指令。

08048604 <do_stuff>:
 8048604:       55          push   ebp
 8048605:       89 e5       mov    ebp,esp
 8048607:       83 ec 28    sub    esp,0x28
 804860a:       8b 45 08    mov    eax,DWORD PTR [ebp+0x8]
 804860d:       83 c0 02    add    eax,0x2
 8048610:       89 45 f4    mov    DWORD PTR [ebp-0xc],eax
</do_stuff>

只有当第二条指令执行后,ebp 寄存器才真正存储了有用的值。当然,前两条指令的基址是由上面所列出来的地址信息表计算出来的。一但 ebp 确定了,计算偏移量就十分方便了,因为尽管 esp 在操作堆栈的时候需要移动,但 ebp 作为栈底并不需要移动。

究竟我们应该去哪里找 my_local 的值呢?在 0x8048610 这块地址后, my_local 的值经过在 eax 中的计算后被存在了内存中,从这里开始我们才需要关注 my_local 的值。调试器会利用 DW_OP_breg5: 8 这个栈帧来查找。我们回想下,my_local 的 DW_AT_location 属性值为 DW_OP_fbreg: -20。所以应当从基址中 -20 ,同时由于 ebp 寄存器需要 +8,所以最终结果为 ebp - 12。现在再次查看反汇编代码,来看看数据从 eax 中被移动到哪里了。当然,这里 my_local 应当被存储在了 ebp - 12 的地址中。

查看行号

当我们谈到在调试信息寻找函数的时候,我们利用了些技巧。当调试 C 语言源代码并在某个函数出放置断点的时候,我们并不关注第一条“机器码”指令(函数的调用准备工作已经完成而局部变量还没有初始化)。我们真正关注的是函数的第一行“C 代码”。

这就是 DWARF 完全覆盖映射 C 源代码中的行与可执行文件中机器码地址的原因。下面是 .debug_line 节中所包含的内容,我们将其转换为可读的格式展示如下。

$ objdump --dwarf=decodedline tracedprog2

tracedprog2:     file format elf32-i386

Decoded dump of debug contents of section .debug_line:

CU: /home/eliben/eli/eliben-code/debugger/tracedprog2.c:
File name           Line number    Starting address
tracedprog2.c                5           0x8048604
tracedprog2.c                6           0x804860a
tracedprog2.c                9           0x8048613
tracedprog2.c               10           0x804861c
tracedprog2.c                9           0x8048630
tracedprog2.c               11           0x804863c
tracedprog2.c               15           0x804863e
tracedprog2.c               16           0x8048647
tracedprog2.c               17           0x8048653
tracedprog2.c               18           0x8048658

很容易就可以看出其中 C 源代码与反汇编代码之间的对应关系。第 5 行指向do_stuff 函数的入口,0x8040604。第 6 行,指向 0x804860a ,正是调试器在调试 do_stuff 函数时需要停下来的地方。这里已经完成了函数调用的准备工作。上面的这些信息形成了行号与地址间的双向映射关系。

  • 当在某一行设置断点的时候,调试器会利用这些信息去查找相应的地址来做断点工作(还记得上篇文章中的 int 3 指令吗?)
  • 当指令造成段错误时,调试器会利用这些信息来查看源代码中发生问题的行。
libdwarf - 用 DWARF 编程

尽管使用命令行工具来获得 DWARF 很有用,但这仍然不够易用。作为程序员,我们希望知道当我们需要这些调试信息时应当怎么编程来获取这些信息。

自然我们想到的第一种方法就是阅读 DWARF 规范并按规范操作阅读使用。有句话说的好,分析 HTML 应当使用库函数,永远不要手工分析。对于 DWARF 来说正是如此。DWARF 比 HTML 要复杂得多。上面所展示出来的只是冰山一角。更糟糕的是,在实际的目标文件中,大部分信息是以非常紧凑的压缩格式存储的,分析起来更加复杂(信息中的某些部分,例如位置信息与行号信息,在某些虚拟机下是以指令的方式编码的)。

所以我们要使用库来处理 DWARF。下面是两种我熟悉的主要的库(还有些不完整的库这里没有写)

  1. BFD (libbfd),包含了 objdump (对,就是这篇文章中我们一直在用的这货),ld(GNU 连接器)与 as(GNU 编译器)。BFD 主要用于 GNU binutils。
  2. libdwarf ,同它的哥哥 libelf 一同用于 Solaris 与 FreeBSD 中的调试信息分析。

相比较而言我更倾向于使用 libdwarf,因为我对它了解的更多,并且 libdwarf 的开源协议更开放(LGPL 对比 GPL)。

因为 libdwarf 本身相当复杂,操作起来需要相当多的代码,所以我在这不会展示所有代码。你可以在 这里 下载代码并运行试试。运行这些代码需要提前安装 libelfand 与 libdwarf ,同时在使用连接器的时候要使用参数 -lelf 与 -ldwarf。

这个示例程序可以接受可执行文件并打印其中的函数名称与函数入口地址。下面是我们整篇文章中使用的 C 程序经过示例程序处理后的输出。

$ dwarf_get_func_addr tracedprog2
DW_TAG_subprogram: 'do_stuff'
low pc  : 0x08048604
high pc : 0x0804863e
DW_TAG_subprogram: 'main'
low pc  : 0x0804863e
high pc : 0x0804865a

libdwarf 的文档很棒,如果你花些功夫,利用 libdwarf 获得这篇文章中所涉及到的 DWARF 信息应该并不困难。

结论与计划

原理上讲,调试信息是个很简单的概念。尽管实现细节可能比较复杂,但经过了上面的学习我想你应该了解了调试器是如何从可执行文件中获取它需要的源代码信息的了。对于程序员而言,程序只是代码段与数据结构;对可执行文件而言,程序只是一系列存储在内存或寄存器中的指令或数据。但利用调试信息,调试器就可以将这两者连接起来,从而完成调试工作。

此文与这系列的前两篇,一同介绍了调试器的内部工作过程。利用这里所讲到的知识,再敲些代码,应该可以完成一个 Linux 中最简单、基础但也有一定功能的调试器。

下一步我并不确定要做什么,这个系列文章可能就此结束,也有可能我要讲些堆栈调用的事情,又或者讲 Windows 下的调试。你们有什么好的点子或者相关材料,可以直接评论或者发邮件给我。

参考
  • objdump 参考手册
  • ELF 与 DWARF 的维基百科
  • Dwarf Debugging Standard 主页,这里有很棒的 DWARF 教程与 DWARF 标准,作者是 Michael Eager。第二版基于 GCC 也许更能吸引你。
  • libdwarf 主页,这里可以下载到 libwarf 的完整库与参考手册
  • BFD 文档


Das obige ist der detaillierte Inhalt vonAnalyse des Funktionsprinzips des Debuggers (3): Eingehende Untersuchung der Debugging-Informationen. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

Stellungnahme:
Dieser Artikel ist reproduziert unter:linuxprobe.com. Bei Verstößen wenden Sie sich bitte an admin@php.cn löschen
Vorheriger Artikel:Ist zsh die beste Shell-Wahl?Nächster Artikel:Ist zsh die beste Shell-Wahl?