Maison >Tutoriel système >Linux >Analyse du principe de fonctionnement du débogueur (3) : étude approfondie des informations de débogage

Analyse du principe de fonctionnement du débogueur (3) : étude approfondie des informations de débogage

WBOY
WBOYavant
2024-01-01 20:05:221386parcourir
Présentation Cet article expliquera à quoi ressemble le débogueur dans le code machine pour les fonctions, les variables et les données du langage C dont il a besoin pour convertir le code source du langage C en code de langage machine.

Il s'agit du troisième article de la série sur le fonctionnement des débogueurs. Vous devriez lire les parties 1 et 2 avant de lire cet article.

Informations de débogage

Les compilateurs modernes peuvent convertir des codes de langage de haut niveau avec divers flux de programme indentés ou imbriqués et des variables de différents types de données en un grand nombre de données 0/1 appelées code machine. Le seul but de cette opération est d'exécuter le programme sur le processeur cible en tant que. Aussi vite que possible. De manière générale, une ligne de code en langage C peut être convertie en plusieurs lignes de code machine. Les variables sont dispersées dans différentes parties du code machine, certaines sur la pile, d'autres dans des registres, ou directement optimisées. Les structures de données et les objets n'existent même pas dans le code machine. Ils sont simplement utilisés pour stocker des données dans le cache selon un certain codage structurel.

Alors, comment le débogueur sait-il où le programme doit s'arrêter lorsque vous devez faire une pause à l'entrée d'une certaine fonction ? Comment sait-il que lorsque vous regardez une valeur de variable, comment trouve-t-il cette valeur ? La réponse est les informations de débogage.

Le compilateur générera également les informations de débogage correspondantes lors de la génération du code machine. Les informations de débogage représentent la relation entre le programme exécutable et le code source et sont stockées avec le code machine dans un format défini à l'avance. Au fil des années, de nombreux formats de stockage de ces informations ont été inventés pour différentes plates-formes et fichiers exécutables. Cependant, cet article ne va pas entrer dans l'histoire de ces formats, mais plutôt comment fonctionnent ces informations de débogage, nous allons donc nous concentrer sur quelque chose comme DWARF. DWARF est désormais largement utilisé comme format de débogage pour les fichiers exécutables sur les plates-formes Linux et Unix.

NAIN en ELF

Analyse du principe de fonctionnement du débogueur (3) : étude approfondie des informations de débogage

Selon sa description Wikipédia, bien que DWARF ait été conçu avec ELF (DWARF est un standard ouvert lancé par le Comité des normes DWARF. L'icône ci-dessus provient de ce site Web.), DWARF peut également être intégré en théorie dans un autre fichier exécutable. formats.

DWARF est un format complexe qui s'appuie sur de nombreuses années d'expérience avec diverses architectures et formats de systèmes d'exploitation. Précisément parce qu'il résout le problème insoluble de la génération d'informations de débogage pour n'importe quel langage de haut niveau sur n'importe quelle plate-forme et ABI (Application Binary Interface), il doit également être complexe. Si vous voulez expliquer DWARF en profondeur, il ne suffit pas de parcourir ce mince article. Pour être honnête, je ne comprends pas complètement DWARF dans ses moindres détails, donc je ne peux pas l'expliquer de manière très approfondie (si vous le pensez). Si vous êtes intéressé, il existe quelques ressources en fin d'article qui peuvent vous aider. Il est recommandé de commencer par le tutoriel DWARF). Dans cet article, je présenterai DWARF d'une manière facile à comprendre pour illustrer le fonctionnement réel des informations de débogage.

Section de débogage dans le fichier ELF

Voyons d’abord où se trouve DWARF dans le fichier ELF. ELF

Nos expériences dans cet article utiliseront le fichier exécutable construit à partir de ce fichier source du langage C et compilé dans tracedprog2 :

#include <stdio.h>

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

    for (i = 0; i 
<p>Utilisez la commande objdump -h pour vérifier l'<ruby>en-tête de section<rt></rt> dans le fichier exécutable ELF. Nous verrons plusieurs sections commençant par .debug_. Ce sont les parties de débogage de DWARF. </ruby>
</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
Le premier chiffre de chaque section représente la taille de la section et le dernier chiffre représente le décalage de la position de départ de la section par rapport à ELF. Le débogueur utilise ces informations pour lire les sections du fichier exécutable.

Examinons maintenant quelques exemples pratiques de recherche d'informations de débogage utiles dans DWARF.

Fonction Rechercher L'une des tâches les plus élémentaires du débogueur est que lorsque nous définissons un point d'arrêt sur une certaine fonction, le débogueur doit pouvoir faire une pause au point d'entrée. Pour ce faire, un mappage doit être établi entre le nom de la fonction dans le code de haut niveau et l'adresse à laquelle commence l'instruction de la fonction dans le code machine.

Afin d'obtenir cette relation de mappage, nous pouvons rechercher la section .debug_info dans DWARF. Avant de plonger dans le vif du sujet, quelques connaissances de base sont nécessaires. Chaque type de description dans DWARF est appelé entrée d'informations de débogage (DIE). Chaque DIE possède des balises sur son type, ses propriétés, etc. Les DIE sont connectés les uns aux autres via des nœuds frères ou des nœuds enfants, et les valeurs des attributs peuvent également pointer vers d'autres DIE.

Exécutez la commande suivante :

objdump --dwarf=info tracedprog2
Le fichier de sortie est assez long. Par souci de commodité, nous nous concentrons uniquement sur ces lignes (à partir de maintenant, je remplacerai les informations longues et inutiles par (...) pour faciliter la composition) :

: 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 文档


Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Cet article est reproduit dans:. en cas de violation, veuillez contacter admin@php.cn Supprimer