正如前面所述,目标文件是编译器采用的最流行的输出机制之一。尽管创建一种只供某个编译器及相关工具使用的专有目标文件格式并不难,然而大多数编译器发出的代码仍用一种或多种标准的目标文件格式。这样就能让不同编译器共用同一套目标文件工具,包括链接器、库管理程序、转储工具、反汇编器等等。常见的目标模块格式包括OMF(Object Module Format)、COFF(Common Object File Format)、PE/COFF(微软推出的COFF变种)和ELF(Executable and Linkable Format),此外还有不少目标文件格式及变种。
多数程序员懂得目标文件表示的是执行应用程序的机器码,但并不清楚目标文件的组织会对应用程序的性能和大小有什么影响。尽管掌握有关目标文件内部表示的详细知识并非我们卓越编程的必要条件,然而对目标文件格式有基本了解,将有助于我们利用编译器和汇编器产生代码的方式,更合理地组织源文件。
目标文件通常以几个字节组成的文件头开始。文件头包括若干标记信息(signature information),用于将文件标识为有效的目标文件,另一些值则指出若干数据结构在文件中的位置。除了文件头外,目标文件通常分成几个区域(section),每一区域包含应用程序数据、机器指令、符号表项、重定位数据及其他有关程序的元数据。实际代码和数据有时只占整个目标代码文件的一小部分。
要想体会目标文件是如何组织的,还是仔细看看某种特定的目标文件格式为妙。下面我们将讨论COFF格式,因为多数目标文件格式(如ELF、PE/COFF)都是基于COFF,或与之相似的。COFF文件的基本布局如图4-9所示。后面几节将会详细说明该格式中的各区域。
图4-9 COFF文件的布局
每个COFF文件以COFF文件头(COFF file header)起始。Microsoft Windows和Linux各自所用的COFF文件头结构如下:
Linux头文件 coff.h 对这些域采用传统的UNIX名字;Microsoft的头文件 winnt.h 采用的名字似乎更容易让人看懂。尽管各有一套字段名和声明方式,这两种定义描述的是同一样东西—COFF头文件。下面是对文件头中每个字段的概述。
f_magic/Machine
标识创建此COFF文件的系统。在初始的UNIX定义中,该值标识生成代码的特定UNIX端口。如今的操作系统对该值的定义还不尽一致,但该值起码能说明COFF文件包含的数据或机器指令是否适合于当前操作系统和CPU。表4-1给出了f_magic/Machine字段的编码。
表4-1 f_magic/Machine字段的编码
续表
f_nscns/NumberOfSections
说明COFF文件中有多少段(区域)。链接器将利用该值遍历各区域头,稍后将说明。
f_timdat/TimeDateStamp
UNIX风格的时间戳,即从1970年1月1日以来的秒数,用来说明文件的创建日期和时间。
f_symptr/PointerToSymbolTable
文件偏移量,即从文件开头算起的字节数,用以说明符号表(symbol table)在文件中的起始位置。符号表是一种数据结构,说明COFF文件中代码用到的所有外部、全局及其他符号的名字等信息。链接器使用符号表来解析外部引用。符号表信息也可能出现在最终的可执行文件中,以供符号调试器使用。
f_nsyms/NumberOfSymbols
该字段指定符号表项的数目。
f_opthdr/SizeOfOptionalHeader
该字段说明可选头区域的字节数。文件头区域后面是可选头区域。即可选头信息首字节位于文件头结构中的f_flags/Characteristics字段之后。链接器或其他目标码操作程序将以此值确定可选头信息在文件的何处结束,以及从何处开始区域头的信息。区域头紧跟着可选头信息,然而可选头信息并不是固定尺寸的。对于不同的COFF文件实现,其可选头结构也各有千秋。如果COFF文件中不存在可选头信息,f_opthdr/SizeOfOptionalHeader应为0,文件头后就是第一个区域头的信息。
f_flags/Characteristics
这是一个很小的位集合,包括若干布尔标志位,指示诸如文件是否可执行、是否有符号信息、是否含有供调试器使用的行号信息等。
COFF可选文件头的内容与可执行文件有关。如果文件内容并非可执行的目标码,存在着未解析的引用,就可能没有可选头。然而请注意,即使文件不可执行,Linux的COFF和Microsoft的PE/COFF文件中仍存在可选头。可选头信息的Windows和Linux结构采用如下C语言形式:
我们马上就能注意到,这两个结构并不一样。Microsoft版本比Linux版本多了不少信息。文件头中的f_opthdr/SizeOfOptionalHeader字段用来指定可选头信息的实际大小。
magic/Magic
为COFF文件提供另一个标记值。该标记值并非指示在哪个系统下创建COFF文件,而是标识文件类型,如COFF。链接器根据该字段中的值来确定它们是否真的在对COFF文件进行操作;并非因随便对某个文件进行操作而迷惑链接器。
vstamp/MajorLinkerVersion/MinorLinkerVersion
说明COFF格式的版本号,以便让处理老版本文件格式的链接器不再试图读取新版本的文件,新版本文件理应交给较新的链接器处理。
tsize/SizeOfCode
说明代码区域的长度。如果COFF文件包含多个代码区域,则这个字段的值不定,但通常指示COFF文件中首个代码/文本区域的大小。
dsize/SizeOfInitializedData
说明此COFF文件中数据段的长度。如果文件中包含两个或以上数据区域,该字段的值同样是未定的。不过一般而言,该字段指示首个数据区域的大小。
bsize/SizeOfUninitializedData
指示BSS(block started by a symbol)区,即符号起始区的大小,未初始化的数据在此区域。同文本和数据区一样,假如有两个或以上的BSS区域,则该字段是不定的,这时该字段值通常为首个BSS区域的大小。
注意: 参看4.7.1节了解关于BSS区域的更多信息。
entry/AddressOfEntryPoint
包含可执行程序的起始地址。类似于COFF文件头里的其他指针,该字段实际上是文件偏移量,并非真正的内存地址。
text_start/BaseOfCode
指示代码区域起始位置在COFF文件中的偏移量。倘若有多个代码区域,则该字段不定,但通常说明COFF文件中第一个代码区域的偏移量。
data_start/BaseOfData
指示数据区域起始位置在COFF文件中的偏移量。如果有多个数据区,则该字段不定,但通常为COFF文件中第一个数据区的偏移量。
没有必要有bss_start/StartOfUninitializedData字段。COFF文件格式假定操作系统的程序加载器在将程序调入内存时,会自动为BSS区域分配存储空间,无须在COFF文件中为非初始化数据花费空间。然而出于性能考虑,一些编译器将BSS和DATA区域合并,4.7节将描述其做法。
可选头结构其实倒退到了UNIX系统用过的老式目标文件格式— a.out 。这就是它无法处理多个文本/代码和数据区域的原因,即使COFF允许有多个区域存在。
Windows版本的可选头其余那些字段存放着程序员想向Windows链接器说明的值。对于手工运行过Microsoft的链接器的人来说,其中大部分字段的意图都显而易见。不管怎样,它们的特定功能在这里并不重要。真正应注意的是,COFF并未要求可选头信息遵循一定的数据结构。各种COFF的实现,例如Microsoft版本,均可自由扩展对可选头信息的定义。
区域头位于可选头信息的后面。不像文件头和可选头,COFF文件可以包含多个区域头。文件头中的f_nscns/NumberOfSections字段指定了区域头(以及区域)在COFF文件中的确切个数。要记住,首个区域头的起始文件位置并不固定。可选头信息的大小不定,而且事实上,如果没有的话,还可能为0,故而应当把文件头中f_opthdr/SizeOfOptionalHeader字段的值加上文件头大小,才能得到首个区域头在文件中的起始位置。而区域头是大小固定的,所以一旦取得首个区域头的地址,就很容易算出其他区域头的地址—只要将期望的区域头序号乘以区域头尺寸,再将其积加上首个区域头的偏移量即可。
下面是Windows和Linux区域头的C语言结构定义:
若仔细查看这两个结构,会发现它们大体是等效的。唯一的结构区别就是Windows重载了物理地址字段,而在Linux中s_paddr总是等价于VirtualAddress字段,同样拥有VirtualSize字段值。
下面是对各字段的概述。
s_name/Name
说明区域的名称。Linux显然将该字段限制为8个字符,故而区域名最长为8个字符。通常假如源文件指定的名字较长,编译器/汇编器会在创建COFF文件时把区域名截尾至8个字符。倘若区域名果真为8个字符,则这8个字符将占据字段内的所有字节,于是没有了零终结字节。倘若区域名少于8个字符,则会在名字后加一个零终结字节。该字段的值往往是.text、CODE、.data或DATA之类的字符串。但是要注意,这个名字并非定义段的类型。创建一个代码/文本区域,照样可以将其命名为“DATA”;也可以创建一个数据区域,命名为“.text”或“CODE”。真正决定区域类型的是s_flags/Characteristics字段。
s_paddr/PhysicalAddress/VirtualSize
多数工具不用这个字段。在类UNIX操作系统如Linux中,该字段一般被设成与VirtualAddress字段相同的值。不同的Windows工具将此字段设为包括0在内的不同值。链接器/加载器似乎不受该字段值的影响。
s_vaddr/VirtualAddress
说明该区域调入内存后的地址,例如其虚拟内存地址。注意这是运行时期的内存地址,并非文件内的偏移量。程序加载器根据该值确定将此区域放到内存的何处。
s_size/SizeOfRawData
说明该区域的大小,单位为字节。
s_scnptr/PointerToRawData
给出该区域数据在COFF文件中的起始偏移量。
s_relptr/PointerToRelocations
给出本区域内重定位列表在文件中的偏移量。
s_lnnoptr/PointerToLinenumbers
为当前区域行号记录的文件偏移量。
s_nreloc/NumberOfRelocations
说明在s_relptr/PointerToRelocations文件偏移处有多少个重定位项。重定位项是小型的数据结构,提供寻址到该区域数据区的文件偏移量,文件调入内存时必须修正。限于篇幅,我们不打算讨论重定位项。如果你对其细节感兴趣,可以查看本章结尾处的参考资料。
s_nlnno/NumberOfLinenumbers
说明在s_lnnoptr/PointerToLinenumbers偏移量处能找到多少个行号记录。行号信息供调试器使用,不属于本章的讲述范围。同样,倘若对行号项的细节感兴趣,可以查看本章结尾处的参考资料。
s_flags/Characteristics
说明本区域有哪些特性的位集合。特别地,该字段会指出本区域是否需要重定位,是否包含代码,是否只读,等等。
区域头提供了描述目标文件中实际数据和代码的目录。s_scnptr/PointerToRawData字段包含了原始二进制数据或代码在文件中的偏移量,而s_size/SizeOfRawData字段则指定了该区域数据的长度。出于重定位的需要,区域块中的数据与操作系统调入内存的数据可能并不完全一致。这是因为区域中的许多指令操作数地址和指针值都可能要基于操作系统将程序调入的位置进行修正,以便重定位文件。与区域数据隔开的重定位列表存放着区域内的偏移值,操作系统必须据此对区域的重定位地址加以修正。修正是在操作系统将区域数据从磁盘调入内存时进行的。
尽管COFF区域中的那些字节不一定是运行时期内存中的确切数据,但COFF要求区域中的所有字节应映射到内存的相应地址,以便让加载程序将区域中的数据直接从文件拷贝到连续的内存单元。重定位操作只是改变区域中某些字节的值,既不插入也不删除区域中的数据。上述要求有助于简化系统加载程序,改善应用程序的性能,因为操作系统在把程序调入内存时无须将大块内存搬来移去。这么做的缺点是COFF格式失去了压缩区域内冗余数据的机会。然而,性能优先于体积正是COFF格式的设计初衷。
COFF的重定位区域存放着特定COFF区域内指针的偏移量,在系统将这些COFF区域内的代码或数据调入内存时,必须对其重定位。
图4-9中的最后3个区域包含调试器(或称为“调试程序”)和链接器用到的信息。其中一个区域包含行号信息,调试器利用行号信息将源代码行与可执行的机器代码指令对照起来。符号表区域和字符串表区域存放着COFF文件的公用及外部符号,链接器以此信息来在目标模块间解析外部引用;调试器则用该信息在调试期间显示符号变量和函数名。
注意: 本书不提供COFF文件格式的完整说明。倘若你对编写诸如汇编器、编译器和链接器之类的应用程序有兴趣,就有必要深入发掘COFF和其他目标码的格式,比如ELF、MACH-O及OMF等等。若要详细研究这个领域,则可参考本章末尾给出的参考资料。