>  기사  >  시스템 튜토리얼  >  디버거의 작동 원리 분석(3): 디버깅 정보에 대한 심층 연구

디버거의 작동 원리 분석(3): 디버깅 정보에 대한 심층 연구

WBOY
WBOY앞으로
2024-01-01 20:05:221338검색
소개 이 문서에서는 디버거가 C 언어 소스 코드를 기계어 코드로 변환하는 데 필요한 C 언어 함수, 변수 및 데이터를 기계어 코드에서 찾는 방법을 설명합니다.

디버거 작동 방식에 관한 시리즈의 세 번째 기사입니다. 이 글을 읽기 전에 1부와 2부를 읽어보세요.

디버그 정보

현대 컴파일러는 다양한 들여쓰기 또는 중첩 프로그램 흐름과 다양한 데이터 유형의 변수가 포함된 고급 언어 코드를 기계어 코드라는 많은 0/1 데이터로 변환할 수 있습니다. 이 작업의 유일한 목적은 대상 CPU에서 다음과 같이 프로그램을 실행하는 것입니다. 가능한 한 빨리. 일반적으로 C 언어 코드 한 줄은 여러 줄의 기계어 코드로 변환될 수 있습니다. 변수는 기계어 코드의 다양한 부분, 즉 일부는 스택, 일부는 레지스터에 분산되어 있거나 직접 최적화되어 있습니다. 데이터 구조와 객체는 기계어 코드에 "존재"하지도 않으며 단지 특정 구조적 인코딩에 따라 캐시에 데이터를 저장하는 데 사용됩니다.

그럼 디버거는 특정 기능 시작 시 일시 중지해야 할 때 프로그램이 중지되어야 하는 위치를 어떻게 알 수 있습니까? 변수 값을 볼 때 해당 값을 어떻게 찾는지 어떻게 알 수 있습니까? 정답은 디버깅 정보입니다.

컴파일러는 기계어 코드를 생성할 때 해당 디버깅 정보도 생성합니다. 디버깅 정보는 실행 가능한 프로그램과 소스코드의 관계를 나타내며, 미리 정의된 형식으로 기계어 코드와 함께 저장된다. 수년에 걸쳐 다양한 플랫폼과 실행 파일을 위해 이 정보를 저장하기 위한 다양한 형식이 개발되었습니다. 그러나 이 기사에서는 이러한 형식의 역사를 다루지 않고 디버깅 정보가 어떻게 작동하는지 다루지 않으므로 DWARF와 같은 것에 중점을 둘 것입니다. DWARF는 이제 Linux 및 Unix 계열 플랫폼에서 실행 파일의 디버깅 형식으로 널리 사용됩니다.

ELF의 난쟁이

디버거의 작동 원리 분석(3): 디버깅 정보에 대한 심층 연구

Wikipedia 설명에 따르면 DWARF는 ELF와 함께 설계되었지만(DWARF는 DWARF 표준 위원회에서 출시한 개방형 표준입니다. 위에 표시된 아이콘은 이 웹 사이트에서 가져온 것입니다.) DWARF는 이론상 다른 실행 파일에 포함될 수도 있습니다. 형식.

DWARF는 다양한 아키텍처 및 운영 체제 형식에 대한 수년간의 경험을 바탕으로 한 복잡한 형식입니다. 모든 플랫폼과 ABI(Application Binary Interface)에서 고급 언어에 대한 디버그 정보를 생성하는 다루기 힘든 문제를 정확하게 해결하기 때문에 복잡할 수밖에 없습니다. DWARF를 자세히 설명하고 싶다면 이 얇은 글을 읽는 것만으로는 부족합니다. 솔직히 저는 DWARF를 세세한 부분까지 완전히 이해하지 못해서 자세히 설명할 수는 없습니다. 관심이 있으시면 기사 끝 부분에 DWARF 튜토리얼부터 시작하는 것이 좋습니다. 이 기사에서는 디버깅 정보가 실제로 어떻게 작동하는지 설명하기 위해 DWARF를 이해하기 쉬운 방식으로 소개합니다.

ELF 파일의 디버그 섹션

먼저 ELF 파일에서 DWARF가 어디에 있는지 살펴보겠습니다. ELF

이 기사의 실험에서는 이 C 언어 소스 파일에서 빌드되고 Tracedprog2로 컴파일된 실행 파일을 사용합니다.

으아악

objdump -h 명령을 사용하여 ELF 실행 파일의 섹션 헤더를 확인하세요. .debug_로 시작하는 여러 섹션이 표시됩니다. 으아악

각 섹션의 첫 번째 숫자는 섹션의 크기를 나타내고, 마지막 숫자는 ELF에서 이 섹션의 시작 위치 오프셋을 나타냅니다. 디버거는 이 정보를 사용하여 실행 파일에서 섹션을 읽습니다.

이제 DWARF에서 유용한 디버깅 정보를 찾는 몇 가지 실제 사례를 살펴보겠습니다.

찾기 기능 디버거의 가장 기본적인 작업 중 하나는 특정 함수에 중단점을 설정할 때 디버거가 진입점에서 일시 중지될 수 있어야 한다는 것입니다. 이를 위해서는 고급 코드의 함수 이름과 기계어 코드의 함수 명령이 시작되는 주소 사이에 일부 매핑이 설정되어야 합니다.

이 매핑 관계를 얻으려면 DWARF에서 .debug_info 섹션을 찾을 수 있습니다. 시작하기 전에 약간의 기본 지식이 필요합니다. DWARF의 각 설명 유형을 DIE(디버그 정보 항목)라고 합니다. 각 DIE에는 해당 유형, 속성 등에 대한 태그가 있습니다. DIE는 형제 노드나 자식 노드를 통해 서로 연결되며, 속성 값은 다른 DIE를 가리킬 수도 있다.

다음 명령을 실행하세요:

으아악

출력 파일은 꽤 깁니다. 편의상 다음 줄에만 중점을 둡니다. (여기서는 조판을 용이하게 하기 위해 쓸모없는 긴 정보를 (...)로 대체하겠습니다.)

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


위 내용은 디버거의 작동 원리 분석(3): 디버깅 정보에 대한 심층 연구의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 linuxprobe.com에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제