第一章 Windows 2000对调试技术的支持
翻译:Kendiv ( fcczj@263.net )
更新:Wednesday, May 25, 2005
声明:转载请注明出处,并保证文章的完整性,本人保留译文的所有权利。
.pdb文件的内部结构
在安装完Windows 20000符号文件之后,你会发现最明显的不同是每个模块都有与其相关的两个符号文件:一个扩展名为.dbg,新增加的那个文件的扩展名为.pdb。粗略察看一下.pdb文件,会发现在其起始位置存放的是这样一个字符串“Microsoft C/C++ program database 2.00 ” 。可以看出PDB是Program Database的首字母缩写。在MSDN中或Internet上搜索一下有关PDB内部结构的信息,你会发现没有任何有用的信息,唯一例外的是,在微软的基础知识文章中,微软申明此种格式是它有的(Microsoft Corporation, 2000d)。就连Windows的老大Matt Pietrek也承认:
“PDB符号表的格式并没有公开的文档。(就连我也不知道其确切的格式,唯一知道的是,它会随着Visual C++的更新而更新。)”(Pietrek 1997a )
或许,pdb格式会随着Visual C/C++一起更新,不过针对当前版本的Windows 2000我可以确切的告诉你PDB符号文件的结构。这或许是首次公开的PDB格式文档。但首先,还是让我们检查一下.dbg和.pdb文件是如何链接到一起的。
Windows 2000的.dbg文件的一个显著特性是:它们包含的数据很少,几乎可以忽略它们的CodeView子节。示例1-8给出了ntsokrnl.exe的.dbg文件所包含的整个CodeView数据,只有区区32字节。这些数据是由w2k_dump.exe工具获取的,可在本书CD的/src/w2k_dump目录下找到该工具的源代码。通常,子节总是以一个CV_HEADER结构开始,该结构中包含CodeView的版本标识。这一次,该版本标识是NB10。MSDN(Microsoft 2000a )没能告诉我们有关这个特殊版本的更多信息:
“NB10,可执行文件的这一标识表示,其调试信息保存在独立的PDB文件中。相应的格式还有NB09或NB11。”(MSDN Library—April 2000/Specifications/Technologies and Languages/Visual C++ 5.0 Symbolic Debug Information Specification/Debug Information Format)
我并不知道NB11格式的内部细节,不过PDB格式和前面讨论的NB09格式一样几乎什么也没有。第一句话很明确的说明了为什么NB10数据块是如此的小。所有相关的信息都被移到了独立的文件中了,因此这个CodeView子节的主要作用就是提供指向实际数据的链接。如示例1-8所暗示的,在ntoskrnl.pdb文件中一定可以找到实际的符号信息。
示例1-8. 一个PDB CodeView子节的十六进制Dump
如果你对示例1-8中剩余数据的作用非常好奇,那么列表1-22或许可以满足你的好奇心。CV_HEADER结构是自解释的。其后的两个成员的偏移量分别为:0x8和0xC,它们的名字分别为:dSignature和dAge,在.dbg和.pdb文件链接的过程中它们将扮演重要角色。dSignature是一个32位的UNIX风格的时间戳,它保存了调试信息构建的日期和时间(自 01-01-1970 以来逝去的秒数)。w2k_img.dll提供了两个函数:imgTimeUnpack()和imgTimePack()用来将dSignature和Windows风格的时间格式进行相互的转化。我还不是非常清楚dAge成员的确切含义。目前知道的是:dAge成员的初始值为1,每次修改PDB数据后其值就会增一。dSignature和dAge共同构成一个64位的ID,调试器可以使用它来验证给定的PDB文件是否与它引用的.dbg文件相匹配。PDB文件在它的一个数据流中包含着两个值的一个副本,因此调试器可以拒绝处理不相匹配的.dbg/.pdb文件。
无论你何时遇到格式未知的数据结构,你应该做的第一件事就是使用十六进制Dump浏览器察看这些结构。本书附带的w2k_dump.exe可很好的完成这一工作。通过检查Windows 2000 PDB文件,如ntoskrnl.pdb或ntfs.pdb,你会发现这些文件拥有如下一些共同特性:
l 这些文件似乎都被划分为多个大小固定的块,一般情况下,每个块的大小为0x400字节。
l 某些块包含一长串1,但偶而会被一小段连续的0打断。
l 文件中的信息并不必须是连续的。有时,数据会在块的边界处突然结束,但又会在文件的其它地方继续开始。
l 有些数据块会在文件中反复出现。
typedef struct _CV_NB10 // PDB reference
{
CV_HEADER Header;
DWORD dSignature; // seconds since 01-01-1970
DWORD dAge; // 1++
BYTE abPdbName[]; // zero-terminated
}
CV_NB10, *PCV_NB10, **PPCV_NB10;
#define CV_NB10_ sizeof(CV_NB10)
列表1-22. CodeView的NB10子节
最终弄清这些复合文件的典型特点花费了我不少时间。复合文件是将一个小型文件系统打包到一个单一文件中。“文件系统”这一修饰词可很好的解释上面得到的观察结果:
l 一个文件系统会将磁盘细化为大小固定的扇区,一组扇区又构成一个文件(此文件件大小可变)。由扇区构成的文件可位于磁盘的任何位置上,并不要求必须是连续的。文件/扇区的对应关系定义在文件目录中。
l 一个复合文件将一个原始磁盘文件细化为大小固定的页,一组页构成一个流(stream),并且流的大小可变。由页构成的复合文件可位于原始文件中的任何位置,这些页并不必须是连续的。流和页的对应关系定义在流目录中。
很显然,文件系统中的格式和复合文件格式差不多是一一对应的,只需简单的将“扇区”替换为“页”,将“文件”替换为“流(Stream)”。对照文件系统可以很好的解释为什么PDB文件是按大小固定的块组织起来的,同时还解释了为什么这些块并不一定都是连续的。不过,一页中几乎都是二进制1的块又代表什么呢?实际上,这种类型的数据在文件系统中是很常见的。为了跟踪磁盘上已用和还未使用的扇区,很多文件系统都维护了一个二进制位的分配数组,数组中的每个二进制位对应文件系统中的一个扇区(或一簇扇区)。如果一个扇区未使用,其对应的二进制位就将被设置为1。当文件系统为文件分配空间时,它就会扫描这个分配位数组,以找出未使用的扇区。在将扇区加入到文件中后,文件系统就将对应得分配位设为0。复合文件的页和流也采用了相似的处理方式。一长串的二进制1代表还未使用的页,二进制0表示对应的页已分配给某个流。
现在唯一的问题就是为什么有些数据块会在PDB文件中反复出现。同样的事情也出现在磁盘的扇区上。当文件系统中的一个文件被多次重写时,每个写操作可能会使用不同的扇区来存放数据。因此,磁盘上某些空扇区中可能会包含旧数据的副本。这在文件系统中不算是什么问题。如果扇区在分配数组中标识为未使用,那么该扇区上有什么数据就无所谓了。这样的扇区很快就会在另一个文件中被使用,其原有内容将被新的数据覆盖掉。对应文件系统的这一特性,我们再来看复合文件,这意味着我们观察到的那些重复的页应该是修改留下的副本。可以安全地忽略它们;我们唯一需要关心的就是那些在流目录(stream directory)中被引用到的页。
现在已经介绍完了PDB文件的基本结构,接下来我们将检查构成PDB文件的那些基本的数据块。列表1-23给出了PDB头部的布局。在PDB_HEADER的开始位置有一个文件字符串给出了当前PDB的版本标识。该标识字符串以EOF字符(ASCII码为0x 1A )结束。在其后还有一个附加的数字:0x 0000474A ,如果将该数字解释为字符串的话,则为:”JG/0/ 0 ” 。或许这代表PDB格式的最初设计者吧。嵌入的EOF字符有一个很好的作用:如果普通用户在控制台窗口中使用type ntoskrnl.pdb,那么将不会显示其后的数据,显示出来的信息只是:Microsoft C/C + + program database 2.00。Windows 2000所有的符号文件都是PDB 2.00版。显然,曾经存在过PDB 1.00格式,而且其结构似乎与现在的有很大不同。
#define PDB_SIGNATURE_200 /
"Microsoft C/C++ program database 2.00/r/n/x1AJG/0"
#define PDB_SIGNATURE_TEXT 40
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
typedef struct _PDB_SIGNATURE
{
BYTE abSignature [PDB_SIGNATURE_TEXT+4]; // PDB_SIGNATURE_nnn
}
PDB_SIGNATURE, *PPDB_SIGNATURE, **PPPDB_SIGNATURE;
#define PDB_SIGNATURE_ sizeof (PDB_SIGNATURE)
// -----------------------------------------------------------------
#define PDB_STREAM_FREE -1
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
typedef struct _PDB_STREAM
{
DWORD dStreamSize; // in bytes, -1 = free stream
PWORD pwStreamPages; // array of page numbers
}
PDB_STREAM, *PPDB_STREAM, **PPPDB_STREAM;
#define PDB_STREAM_ sizeof (PDB_STREAM)
// -----------------------------------------------------------------
#define PDB_STREAM_MASK 0x0000FFFF
#define PDB_STREAM_MAX (PDB_STREAM_MASK+1)
#define PDB_STREAM_DIRECTORY 0
#define PDB_STREAM_PDB 1
#define PDB_STREAM_TPI 2
#define PDB_STREAM_DBI 3
#define PDB_STREAM_PUBSYM 7
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
typedef struct _PDB_ROOT
{
WORD wCount; // < PDB_STREAM_MAX
WORD wReserved; // 0
PDB_STREAM aStreams []; // stream #0 reserved for stream table
}
PDB_ROOT, *PPDB_ROOT, **PPPDB_ROOT;
#define PDB_ROOT_ sizeof (PDB_ROOT)
#define PDB_PAGES(_r) /
((PWORD) ((PBYTE) (_r) /
+ PDB_ROOT_ /
+ ((DWORD) (_r)->wCount * PDB_STREAM_)))
// -----------------------------------------------------------------
#define PDB_PAGE_SIZE_1K 0x0400 // bytes per page
#define PDB_PAGE_SIZE_2K 0x0800
#define PDB_PAGE_SIZE_4K 0x1000
#define PDB_PAGE_SHIFT_1K 10 // log2 (PDB_PAGE_SIZE_*)
#define PDB_PAGE_SHIFT_2K 11
#define PDB_PAGE_SHIFT_4K 12
#define PDB_PAGE_COUNT_1K 0xFFFF // page number < PDB_PAGE_COUNT_*
#define PDB_PAGE_COUNT_2K 0xFFFF
#define PDB_PAGE_COUNT_4K 0x7FFF
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
typedef struct _PDB_HEADER
{
PDB_SIGNATURE Signature; // PDB_SIGNATURE_200
DWORD dPageSize; // 0x0400, 0x0800, 0x1000
WORD wStartPage; // 0x0009, 0x0005, 0x0002
WORD wFilePages; // file size / dPageSize
PDB_STREAM RootStream; // stream directory
WORD awRootPages []; // pages containing PDB_ROOT
}
PDB_HEADER, *PPDB_HEADER, **PPPDB_HEADER;
#define PDB_HEADER_ sizeof (PDB_HEADER)
列表1-23. PDB_HEADER结构
在标识字符串之后偏移量为0x 2C 处有一个名为dPageSize的DWORD类型的值,它代表的是复合文件中每个页所占的字节数。合法的值可以是:0x0400(1KB)、0x800(2KB)和0x1000(4KB)。wFilePages成员记录了PDB文件使用的页的总数。将wFilePages与dPageSize相乘即可得到该PDB文件的大小。wStartPage是一个从零开始的页码,它指向第一个数据页。该页的字节偏移量可由该页的页码乘以每页的大小得到。通常的值为:页号为9的1KB页(字节偏移量为0x2400),页号为5的2KB页(字节偏移量为0x2800)或者页号为2的4KB页(字节偏移量为0x2000)。在PDB_HEADER和第一个数据页之间的空间保留给分配位数组,并总是从第二个页开始。这意味着,如果页大小为1或2KB,则PDB文件使用0x10000(64K)个分配位,每位对应0x2000字节(8KB)的页,如果页大小为4KB,则使用0x8000(32K)个分配位,每位对应0x1000字节(4KB)的页。以此类推,这意味着,在页大小为1KB的情况下,PDB文件可容纳64MB数据,在页大小为2KB或4KB的情况下,PDB文件可容纳128MB数据。
PDB_HEADER最后的RootStream和wRootPages[]成员记录了PDB文件中流目录的位置。就像前面提到的,PDB文件是由一组长度可变的流构成的,这些流中才包含有实际的数据。流的位置及其内容是由一个单一的流目录管理的。流目录自身也存储在一个流中。我称这个特殊的流为“Root Stream”。Root Stream中保存着流目录(该流目录可能位于PDB文件的任何位置)。PDB_HEADER的Rootstream和wRootPages[]成员提供了Root Stream的位置和大小。PDB_STREAM子结构的dStreamSize成员给出了流目录占用的页的数目,这些页的首地址保存在wRootPages[]数组中,这些页包含实际的数据。
现在让我们用一个小例子来说明这一点。示例1-9给出了ntoskrnl.pdb的PDB_HEADER的十六进制Dump的部分内容。这里引用到的值由下划线标识出来。显然,这个PDB文件使用的页的大小为0x400字节(1KB),一共使用了0x02D1(721)个页,这样该文件的大小则为0xB4400(十进制738,304)。使用dir命令可验证这个大小是正确的。Root Stream的大小为0x5B0字节(1456字节),由于每个页的大小为0x400字节(1KB),则意味着wRootPages[]数组中包含两项,分别位于偏移量为0x 3C 和0x3E处。数组中的两项内容都是页码,需要将此页码与页大小相乘才能得到对应的字节偏移量。此处,该字节偏移量为:0xB2000和0xB2800。
上面最后一行给出的计算结果是ntoskrnl.pdb文件的流目录所占用的两组文件页的首地址,其范围分别为:0xB2000----0xB23FF和0xB2800----0xB29AF。示例1-10给出了这些范围的部分内容。
示例1-9. PDB文件头示例
示例1-10. PDB流目录摘要
流目录由两个部分构成:一个PDB_ROOT结构的文件头部分,该结构定义在列表1-24中,另一部分是由16位页码构成的数组。PDB_ROOT结构中的wCount成员记录了保存在PDB文件中的流的数目。aStream[]数组包含多个PDB_STREAM结构(参见列表1-23),每个PDB_STREAM结构代表一个流,紧随aStream[]数组之后在就是页码数组。在示例1-10中,流的个数为8,对应的偏移量为0xB2000,该位置已用下划线标识出。随后的8个PDB_STREAM结构分别给出了这8个流的大小:0x5B0、0x 3A 、0x38、0x 402A 9、0x0、0x4004、0x19EB4和0x4DF 3C 。这些值也都以下划线标识出。在1KB页模式下,流的大小为:0x2、0x1、0x1、0x101、0x0、0x11、0x68和0x138,这样可计算出这些流总共占用了0x2B6个页。在PDB_STREAM数组之后,第一个以下划线标识出来的值是页码列表中的第一个页码。这里每个页码占用2个字节,这里需要考虑的是,页目录被属于其他部分的一个页截断了,故页目录随后的偏移量为应为:0xB2044+0x400+(0x2B6*2)=0xB29B0,示例1-10很好的展示了这一点。
#define PDB_STREAM_DIRECTORY 0
#define PDB_STREAM_PDB 1
#define PDB_STREAM_TPI 2
#define PDB_STREAM_DBI 3
#define PDB_STREAM_PUBSYM 7
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
typedef struct _PDB_ROOT
{
WORD wCount; // < PDB_STREAM_MAX
WORD wReserved; // 0
PDB_STREAM aStreams []; // stream #0 reserved for stream table
}
PDB_ROOT, *PPDB_ROOT, **PPPDB_ROOT;
#define PDB_ROOT_ sizeof (PDB_ROOT)
#define PDB_PAGES(_r) /
((PWORD) ((PBYTE) (_r) /
+ PDB_ROOT_ /
+ ((DWORD) (_r)->wCount * PDB_STREAM_)))
列表1-24. PDB的流目录结构
要找到给定的流所对应的页码需要一定的技巧,因为页目录除了流的大小之外,没有提供任何信息。如果你对3号流感兴趣,那么你必须计算流1和流2所占用的页的数目,以获取3号流在页码数组中的起始索引。一旦定位了指定流的页码列表,读取流中的数据就很简单了。只需要遍历页码列表,将列表中的每个页码和每页的大小相乘,就可获得此页码对应页的文件偏移量,然后从该偏移量处开始读取页的内容,反复如此,直到到达流的结束处,就可读取整个流的内容了。猛地一看,解析一个PDB文件似乎非常费劲。但从另一个角度看却十分简单-----因为这要比解析一个.dbg文件简单的多。PDB格式的这种清晰的随机访问机制,将读取一个流的任务简化为读取连续的大小固定的页。这种优雅的数据访问机制让我很是吃惊。
当更新一个已存在的PDB文件时,PDB格式的优势就非常明显了。将使用连续的结构体的数据插入到一个文件中,意味着将移动大量的原有数据。PDB文件从文件系统借鉴来的随机访问架构允许以最小的开销完成删除或插入数据的操作,就像文件系统中的文件可以很容易的修改一样。当一个流在增大时,只需改动流目录或则收缩页的边界。这种非常重要的特性大为提高了PDB文件更新的灵活性。微软在基本知识库中正式提供这样一片文章:“信息:PDB和DBG文件-----它们是什么以及它们是如何工作的”:
“.PDB扩展了“Program database”架构。此种文件用来存放调式信息,这种格式随Visual C++ 1.0一起引入。在将来,.PDB文件还将包含其它的项目状态信息。格式改变的一个重要动机是为了允许程序调试版的增量链接,第一次改变随Visual C++ 2.0引入。”(Microsoft Corporation 2000e)
现在PDB文件的内部结构已经很清晰了,下一个问题是如何识别这些流的具体内容。在检查完PDB文件的各个方面后,我得出这样一个结论:每一种流都用于特定的目的。第一个流似乎总是包含一个流目录,第二个流包含用于验证该PDB文件是否与其关联的.dbg文件相匹配的信息。例如,该流中包含的dSignature和dAge成员应该和NB10 CodeView节中的对应成员一致,如列表1-22所示。第八个流是本章最感兴趣一个,因为该流中包含我们要探索的CodeView的符号信息。其余流的作用我还不是很清楚,这是将来要研究得一个方向。
我没有提供PDB读取程序的示例代码,因为这已超出本章的范围。替代的,我鼓励你深入研究一下本书CD中的w2k_img.c和w2k_img.h。重点是imgPdb*()函数和PDB_*类型的数据。顺便说一下,本书CD中包含一个完整的PDB流读取程序的完整源代码。你已经用过这个程序了,它就是w2k_dump.exe。前面的一些例子就是我用这个工具生成的。这个简单的控制台程序使用+p命令选项来允许解析PDB流。如果指定的文件不是一个有效的PDB文件,那么该程序将仅进行连续的十六进制Dump。可在本书CD的/src/w2k_dump目录下找到w2k_dump.exe的Visual C/C++项目文件。