什么是ELF?了解一个英文缩写要首先从它的全称开始看起。ELF的英文全称是Executable and Linkable Format(可执行与可链接格式)。扔掉限定词,ELF就是一种格式。

接着,了解计算机科学中的一个概念,还需要了解它的历史渊源。

在1999年的时候,ELF这种格式被x86架构上的类Unix操作系统的二进制文件标准格式。它被用来取代当时的COFF,取代的原因是ELF具备更强的可扩展性和灵活性,能够应用于其他处理器和其他计算机架构的操作系统上。这里的COFF可以猜到是另外一种文件格式。ELF在很大程度上是基于COFF的具体实现的,我们简要介绍一下COFF。

COFF的全称为Common Object File Format(通用对象文件格式),COFF统一了目标文件的格式为混合语言编程带来了极大的方便。这里的目标文件,就是编译器产生的目标文件,常见后缀为.o和.obj。当然COFF的范围远不限于目标文件,包括在内的还有库文件、可执行文件都是COFF的格式。

COFF的文件数据种类共有8种,分别是

  1. 文件头(File Header)
  2. 可选头(Optional Header)
  3. 段落头(Section Header)
  4. 段落数据(Section Data)
  5. 重定位表(Relocation Directives)
  6. 行号表(Line Numbers)
  7. 符号表(Symbol Table)
  8. 字符串表(String Table)

除了其中的段落头可以有多个节(因为可以有多个段落),其他的部分至多只有一个。接下来分别概述每一个数据种类的作用:

  • 文件头:作为COFF文件的文件头,在其中保存有关该文件的基本信息,像文件标识、各个表的位置等。
  • 可选头:顾名思义,可有可无的头。通常在目标文件中都没有这一部分,然而在其他如可执行文件中就存在这个部分,通常用来补充说明文件头中缺失的信息。
  • 段落头:用来描述段落信息,每一个段落都有一个段落头来说明,段落的数目也会在文件头中指出。
  • 段落数据:通常为COFF中最大的数据段,每个段落保存的真正数据就在这个部分,每一个部分的具体区分方法参见段落头给出的信息。
  • 重定位表:通常在目标文件中出现,用来描述COFF文件中符号的重定位信息。重定位指的是把程序的逻辑地址空间变换成内存中的实际物理地址空间的过程,它是实现多个程序在内存中同时运行的基础。
  • 符号表:保存COFF中所用到的所有符号的信息。符号表在计算机科学中,是一种用于编译器或解释器中的数据结构。程序的源代码中的每个标识符都和它的声明或使用信息绑定在一起。这个表可以帮助我们重定位符号,在调试程序的时候也得以使用。
  • 字符串表:用于保存字符串。在早期符号表是以记录的形式来描述符号信息的,但它只为了符号名称留置了8个字符的空间,显然是不够用的。没有办法只能将名称存放在字符串表中,而符号表中只记录这些字符串的位置。

接下来是逐个段落的详细分析

文件头

文件头是从文件的0偏移处开始的,C语言下的结构描述为

typedef struct {
unsigned short usMagic; //魔法数字
unsigned short usNumSec; // 段落(Section)数
unsigned long ulTime; //时间戳
unsigned long ulSymbolOffset; // 符号表偏移
unsigned long ulNumSymbol; // 符号数
unsigned short usOptHdrSZ; // 可选头长度
unsigned short usFlags; // 文件标记
} FILEHDR;
  • usMagic成员是一个魔法数字,可以认定为是一个平台标识,在不同的平台上具有不同的魔法数字,例如在i386平台上,魔法数字为0x014c。
  • usNumSec用来描述段落的数量。
  • ulTime是一个时间戳,它用来描述COFF时间的建立时间。当COFF文件为一个可执行文件时,这个时间戳经常用来当做一个加密比对的标识。
  • ulSymbolOffset表示符号表在文件中的偏移量,是一个绝对偏移量,从文件头开始计数。在COFF中的其他偏移量都是类似的绝对偏移量。
  • ulNumSymbol成员给出了符号表中符号记录的数量。
  • ulOptHdrSZ是可选头的长度,通常为0。其中通过这个成员可以读出可选头的不同长度,不同长度对应了不同可选头的类型,便于进行针对处理。
  • usFlag是COFF的属性标记,标识了COFF文件的类型,文件中所保存的数据等等信息。具体如图
Flag bits

可选头

可选头接在文件头后面,从COFF文件的0x0014偏移处开始。不同长度的可选头结构也不同,标准的可选头长度为24或28字节(28居多)。以28字节为例的头结构如下:

typedef struct {
unsigned short usMagic; // 魔法数字
unsigned short usVersion; // 版本标识
unsigned long ulTextSize; // 正文(text)段大小
unsigned long ulInitDataSZ; // 已初始化数据段大小
unsigned long ulUninitDataSZ; // 未初始化数据段大小
unsigned long ulEntry; //入口点
unsigned long ulTextBase; // 正文段基址
unsigned long ulDataBase; //数据段基址(在PE32中才有)
} OPTHDR;
  • usMagic这时应该是0x010b或0x0107。0x010b表示COFF文件是一个一般的可执行文件。0x0107表示COFF文件是一个ROM镜像文件。
  • usVersion标识COFF文件的版本。
  • ulTextSize表示可执行COFF的正文段长度
  • ulEntry是程序的入口点,也就是COFF文件载入内存时正文段的位置(EIP寄存器的值)。当COFF文件是一个动态库时,入口点就是动态库的入口函数。

段落头

必不可少的节(同样还有文件头)。段落头在可选段的后面(如果可选头的长度为0,即紧跟在文件头后)。长度为40个字节,如下:

typedef struct {
char cName[8]; // 段名
unsigned long ulVSize; // 虚拟大小
unsigned long ulVAddr; //虚拟地址
unsigned long ulSize; // 段长度
unsigned long ulSecOffset; // 段数据偏移
unsigned long ulRelOffset; // 段重定位表偏移
unsigned long ulLNOffset; // 行号表偏移
unsigned short usNumRel; // 重定位表长度
unsigned short usNumLN; // 行号表长度
unsigned long ulFlags; // 段标识
} SECHDR;
  • cName用来保存段名,常见有.text(正文段、代码段),.data(数据段,保存初始化过的数据),.bss(保存未初始化的数据),.comment(COFF文件的注释信息)。
  • ulVSize是段数据载入内存时的大小。只在可执行文件中有效,在目标文件中为0。如果长度大于段的实际长度,多余部分用0补齐。
  • ulVAddr是段数据载入或链接时的虚拟地址。对于可执行文件来说是相对于文件地址空间而言的段中数据的第一个字节的位置。对于目标文件而言,是重定位时段数据当前位置的一个偏移量,因计算简化通常设为0。
  • ulSize段数据的实际长度。
  • ulSecOffset指的是段数据在COFF文件中的偏移量。
  • ulRelOffset指的是该段的重定位信息的偏移量,指向了重定位表的一个记录。
  • ulNOffset指的是段的行号表的偏移量,指向行号表中的一个记录。
  • usNumRel是重定位信息的记录数。从ulRelOffset指向的记录开始,到第ulNumRel个记录开始都是该段的重定位信息。
  • ulFlags是该段的属性标识,具体值如下图:
Flag bits

段落数据

段落数据是保存各个段的数据的位置。在目标文件中,这些数据都是原始数据(Raw Data)。不同类型的段,数据的内容、结构都不相同。

重定位表

重定位表保存的是各个段的重定位信息。可以把整个重定位表看成多个重定位表,每个段落都有一个自己的重定位表,这个表只在目标文件中有,可执行文件不存在这个表。重定位表中每一条记录就是一条重定位信息,如下:

typedef struct {
unsigned long ulAddr; // 定位偏移
unsigned long ulSymbol; // 符号
unsigned short usType; // 定位类型
} RELOC;
  • ulAddr指的是要定位的内容在段内的偏移。若一个正文段起始位置为0x10,ulAddr为0x05,则定位信息就要写在0x15处。
  • ulSymbol为符号索引,指向符号表的一个记录。指明了重定位信息所对应的符号。
  • usType是重定位类型的标识。在32bit下通常只有两种定位,分别为绝对定位和相对定位,如图:
Two Types of Relocations

在i386下的定位方式详解

  • 绝对定位:给出符号的绝对地址。使用符号的相对地址+它所在段的相对地址来得到它的绝对地址。这些偏移量从段落头和符号表中得到,当然首先执行重定位操作。
  • 相对定位:相对于当前位置的偏移。当前位置为ulAddr的定位偏移+当前段偏移+机器字长/8,相对地址为符号的绝对地址减去当前地址即可。
  • 计算好了地址写入ulAddr所指向的位置,就完成了重定位的工作。

行号表

行号表在调试的时候非常有用,它把可执行的二进制代码与源代码之间的行号之间建立了对应关系。当程序执行不正确是就可以知道出错源代码的行号,再加以修改。具体格式为:

typedef struct {
unsigned long ulAddrORSymbol; // 代码地址或符号索引
unsigned short usLineNo; // 行号
} LINENO;
  • ulAddrORSymbol在行号大于0时代表源代码的地址,当行号为0时表示行号所对应的符号在符号表中的索引
  • usLineNo是一个从1开始的计数器,它代表源代码的行号

符号表

符号表是对象文件中用来保存符号信息的一张表,是COFF文件中最为复杂的一张表。所有段落用到的符号都在这个表里保存,结构为:

typedef struct {
union {
char cName[8]; // 符号名称
struct {
unsigned long ulZero; //字符串表标识
unsigned long ulOffset; // 字符串偏移
} e;
} e;
unsigned long ulValue; // 符号值
short iSection; // 符号所在段
unsigned short usType; // 符号类型
unsigned char usClass; // 符号存储类型
unsigned char usNumAux; // 符号附加记录数
} SYMENT;
  • cName当符号名称只有8个字符就可以放入,否则放到字符表中。此时ulZero值为0,ulOffset中会给出所用的符号的名称在字符串表中的偏移。
  • ulValue指代符号所代表的值
  • iSection指出符号所在段落。如果值为0,这个符号就是外部符号,需要从其他的COFF文件中解析,连接多个目标文件就是要解析这种符号。值为-1,说明符号的值是一个常量而不是段落中的偏移。值为-2,说明这个符号为调试符号,只有在调试的时候会用到。值大于0,才是符号所在段的索引值。
  • usType为符号类型标识。低四位为基本标识,指出符号的基本类型(整型、字符、结构、联合等),高四位指出符号的高级类型(指针、函数、数组、无类型等)。编译器通常不使用基本类型只使用高级类型,所以符号的基本类型通常设为0
  • usClass是符号的存储类型标识,指明了符号的存储方式,具体为:
this tells where and what the symbol represents
  • usNumAux用来描述符号的一些附加信息,为了便于保存,这些附加记录通常选择成为一条符号信息记录的整数倍。如果值为1,就表示在当前符号信息记录后面附加了一条记录,用来保存附加信息。

字符串表

字符串表是用来保存字符串的,字符串的前四个字节表示字符串表的长度,以字节为单位。在后面就是C风格字符串。需要注意的是长度包括\0,长度域的四个字节。符号表中的ulOffset成员指出的偏移就是从字符串表起始处的偏移。指向第一个字符串的符号ulOffset的值总为4。

至此COFF结构分析完毕,在此基础上可以继续学习ELF、PE和OMF的文件格式。

参考资料

DJGPP COFF Spec http://www.delorie.com/djgpp/doc/coff/


Melancholy.