调试符号概览

为了具有完全的源码级别调试能力,编译器需要生成许多调试符号信息,它们可以根据描述的对象分类如下:

  • 全局函数和变量

    这一类包含了在各个编译单元可见的全局符号类型和位置信息。全局变量具有相对它们属于的加载模块基址的固定地址。它们在当程序退出或者程序运行时调用链接器API显示地卸载模块前都是有效和可访问的。因为可见性、固定位置和长的生命周期,全局变量在任何时候和任何位置都是可以调试的。这意味着一个调试器在全局变量整个生命期内,无论程序在运行哪一个分支,都可以对数据进行观察、改变和设置断点。

  • 源文件和行信息

    众多调试器的主要特性中,有一个特性,使得用户可以在程序源语言的上下文,在源码级别跟踪和监测一个被调试的程序。这个功能依赖将一系列指令映射为源文件一行的源文件和行数的调试符号。因为一个函数是占据连续内存空间的可执行代码的最小单元,源文件和行号调试符号记录着每个函数的开始和结束地址。当编译器将一行源代码翻译为一群机器指令,同时它也生成行号调试信息,用于跟踪对应这一行的指令地址。当为了提高程序的性能或者减少生成机器码的大小,多行源代码会被编译器移来移去,情况可能会变得复杂。 由一行源代码生生成的指令可能在地址空间不是连续的。它们可能跟其他源代码行交织在一起。宏和内联函数使得境况变得更复杂。

  • 类型信息

    类型调试符号描述了一个数据类型的组合关系和属性,要么是原始的数据,要么是其他数据的聚合。对于组合类型,调试符号包含每一个子字段的名字、大小和相对整个结构开头的偏移。一个子字段可以指向其他组合类型,而这些组合类型的调试符号在其他地方定义。调试需要一个对象的类型信息,从而能够以程序源码语言的形式打印它。否则,它会是内存内容的原始比特和字节。对于复杂的语言比如C++,这是特别有用的,因为为了实现语言的语义,编译器添加了隐藏的数据成员到数据对象里面。这些隐藏的数据成员是依赖编译器实现。检验对象内存值时,将它们从”真正“的数据成员区分开来非常困难。类型信息也包含了函数签名和其他的链接属性。

  • 静态函数和局部变量

    跟全局符号相反,静态函数和局部变量仅仅在特定的作用域可见:一个文件,一个函数,或者一块被包围的作用域。一个局部变量仅仅在作用域存在和有效,所以说它是临时的。当线程的执行流运行出作用域,作用域的局部变量会被销毁和在语义上变得无效。基于局部变量在栈上分配或者跟容易失效的寄存器挂钩,它的存储位置在程序运行到这个作用域之前都是不可知的。因此,调试器仅仅可以在特定的作用域对变量进行观察、修改和设置断点,这有时是困难的。局部变量的调试符号包含作用域的信息,也包含局部变量的位置。作用域通常表示为指令的范围和相对函数栈帧的偏移的位置。

  • 架构和编译器依赖信息

    一些调试功能是跟特定架构和编译器相关。举个例子,英特尔芯片的FPO (Frame Pointer Omission,栈指针省略),微软Visual Studio的修改和运行功能,等等。

正如你可以想象的,通过调试符号,从编译器向调试器传达所有的调试信息不简单。相对生成的机器代码,编译器生成许多调试符号,即使简单的程序也如此。因此,调试符号通常会编码来减少大小。

不幸的是,没有标准指明如何实现调试符号。编译器厂商因历史在不同的平台采用不同的调试符号格式。举个例子,Linux,Solaris和HP-UX现在使用 DWARF (Debugging with attributed Record Formats); AIX和老版本的Solaris使用stabs(symbol table string);Windows有多种在用的格式,最受欢迎的是程序数据库或者pdb。调试符号格式的文档通常要么难找要么不全。它自己也持续随着编译器新的发布而演进。在这之上,工具厂商在他们自己的编译器和调试器有各种拓展。

结果就是,通常在特定平台打包在一起的编译器和调试器的调试符号格式在或多或少是一种秘密的协议。多亏开源社区,DWARF在这方面是比较好的。因此我将在接下来的章节里使用它来作为调试符号是怎么实现的例子。