符号级调试基础

内容回顾

前面我们介绍了指令级调试过程中对tracee的各种控制,线程跟踪、执行到断点、单步执行、读写内存、读写寄存器等等,这些也是符号级调试必备的控制能力。前面提过一个设计良好的符号级调试器至少要做到3层架构,包括UI层、符号层、目标层,这样软件结构组织上清晰、扩展性也更好:

debugger-arch-1

一起来回顾下调试器的三层架构:

  • UI层 (UI layer),主要负责与用户交互,1)执行调试动作,如添加断点、单步执行等;2)展示调试信息,如变量值、堆栈信息等。分离出UI层便于将交互和展示逻辑与核心调试逻辑分离开,便于更改或支持不同的用户界面。
  • 符号层 (Symbolic Layer),主要负责解析调试符号信息,如理解变量、函数、源码位置与内存指令和数据地址的转换、调用栈等,它是连接用户UI操作与对目标程序执行控制的桥梁,比如我们打印一个变量值时通过变量名来打印,添加断点时断点位置使用源码file:lineno来添加。分离出符号层,可以更容易地支持不同的调试信息格式。
  • 目标层 (Target Layer),目标层直接与被调试程序交互,负责执行调试命令对进程进行控制、数据读写,如设置断点、单步执行、读取内存和寄存器数据等。分离出目标层,可以更方便地支持不同的平台,如支持不同的操作系统、硬件架构。第六章实现指令级调试时对进程的控制能力,会下沉一部分到目标层。

面临挑战

符号级调试,依赖于调试信息标准指导下编译器、链接器生成的调试信息。调试信息目前有多种,其中DWARF(Debugging With Attributed Record Formats)现在被广泛使用。go语言编译工具链也是采用的DWARF,调试器gdb、delve也支持DWARF。

我们准备好认识DWARF了吗?恐怕还没有。在详细介绍DWARF对不同编程语言的强大描述能力之前,我需要先假设读者对编译工具链的认识还没有那么充分(事实可能果真如此),这里做最坏的打算、补充一些必要的知识,以让大部分读者朋友可以在此基础上顺利过渡到DWARF章节,然后我们再一起轻装上阵进入符号级调试器的开发部分。如果读者对这方面很熟,也可以适当加快浏览速度。

OK,那我们迅速总结下,实现符号级调试器,除了指令级调试部分我们已掌握的内容,接下来还需要攻克的就是了解清楚go编译器、链接器如何借助DWARF来描述go语言的不同程序构造,这样调试器读取了go程序中的DWARF调试信息后,也可以知道描述的具体是go语言里的什么程序构造。

以Linux文件格式ELF文件为例,编译器、链接器负责生成DWARF调试信息,并将其存储在ELF文件的 .(z)debug_ sections中。从DWARF标准来看,根据描述对象的不同,DWARF调试信息可以细分为下面这些大类:

  • 描述数据类型;
  • 描述变量;
  • 描述函数定义;
  • 描述行号表;
  • 描述调用栈信息表;
  • 描述符号表;
  • 描述字符串表;
  • 等等。

编译工具链除了生成DWARF调试信息,也会考虑语言运行时本身的一些特性支持,这会添加一些语言独有的sections。还需要要考虑生成来兼容现有二进制工具的一些常见的sections。比如go语言编译器、链接器会生成DWARF调试信息(.[z]debug_* sections)供调试器使用,它还额外生成.gosymtab、.gopclntab用于go runtime来跟踪调用栈信息,生成.note.go.buildid来保留go buildid信息。另外,也会生成.symtab供readelf等通用的二进制分析工具使用。

符号级调试的实现,要依赖DWARF,但是不是完全依赖DWARF还是要看具体实现。这要看编译器、链接器有没有生成足够完备的调试信息,或者调试信息解析效率是否足够高。有些语言的编译工具链没有做到这个程度,或者使用的DWARF版本对数据格式设计解析起来没那么高效,有些调试器就会退而求其次,去读取一些其他的ELF sections来帮助实现调试功能,或者帮助改善调试效率、改善调试体验。

所以说,实现符号级调试器,理论上来说可以借助DWARF来实现,但是工程上要考虑更多现实问题。实现一个高效可用的符号级调试器,需要认识到这个地方在以前可能是个挑战。现在应该不用担心了,go-delve/delve就是完全借助DWARF,而gdb还是用了部分符号表中的信息。

本章目标

本章节我准备介绍下Linux下可执行程序常用的ELF文件格式、sections和segments的区别,以及编译器、链接器、加载器是如何协同工作的。我们写的程序,是如何从源码到可执行程序,到被加载到内存地址空间,被操作系统进程调度器调度执行。然后,我们会简单介绍下go语言相关的一些有趣的特性实现,比如协程。这个过程中,我们会介绍编译器、链接器为什么要生成某些sections、segments,以及segments如何被loader加载到进程地址空间,如何完成符号解析、重定位。在本章之后,读者会对编译工具链、ELF文件中的各个部分有个更清晰的认识,会充分认识到这是一个经过了精妙设计的协作生态。

如果我们选择跳过这一章的话,可能存在如下问题:1)读者可能不熟悉ELF、编译器、链接器的工作原理,短时间内也难以理顺,很可能在这里碰壁后失去继续下去的信心。2)调试器设计实现也确实离不开这部分知识,还不如系统性地把这个浑水给趟完,免得读者还要自己去搜索各种资料来补齐。3)我们会经常提到一些术语,比如符号在多个场景下有使用但是实则是不同的东西,读者不了解本章内容很可能会搞混很多技术细节。

所以,这一章最后还是和大家见面了,大家读完后能有个更全面的认识。本章先介绍一些ELF基础知识,包括一些重要的sections、segments是干什么用的,然后介绍下编译器、链接器的工作过程,它们是怎么借助ELF中的某些sections数据的,以及它们将DWARF调试信息生成到什么为止,大致如何进行查看。然后第8章我们可以介绍下DWARF调试信息是如何描述程序的,我们将在第9章进入符号级调试开发。大家也可以带着一个目标阅读本章,最起码要知道DWARF调试信息是何人何时何地生成的、存储在哪里、由谁读取并利用、如何读取。

ps:关于“符号”这个术语,其在不同阶段扮演着不同的角色,携带的信息也各有侧重:

  • .symtab 中的符号信息 主要供链接器在链接时进行符号解析和重定位,或者在程序加载时由动态链接器(loader)使用。这些信息通常包含函数地址、全局变量地址等,用于将不同的代码段和数据段组合成可执行文件。
  • .debuginfo (或 .debug*) 中的信息 主要面向调试器,用于提供源码级别的调试信息,例如类型名、变量名、函数名等,这些信息以 DWARF (Debugging With Attributed Record Formats) 的 DIE (Debugging Information Entry) 形式存储。调试器利用这些信息进行符号显示、断点设置、单步调试等操作。
  • 编译器在词法分析、语法分析和语义分析阶段 会对源码中的类型名、变量名、函数名等符号进行分析,并生成包含更详细信息的内部符号表(不是.symtab)。这些信息不仅用于类型安全检查等分析过程,也为后续的优化和代码生成提供依据。

注意不同情景下提到“符号”这个术语,读者不要混淆相关的含义和技术细节。

results matching ""

    No results matching ""