静态链接器构造

静态链接器构造

 

一、             链接器简介

众所周知,高级语言程序编写后需要经过编译、汇编、链接、加载的步骤才能在机器上正常执行。Gcc的处理步骤更加复杂:如果是C语言程序(*.c)作为gcc的输入,gcc首先对高级语言程序进行预编译,然后利用文法分析程序将程序翻译为通用的中间代码,接着gcc对中间代码进行优化后最终生成x86的汇编程序(*.S);下一步gcc的汇编器开始工作,将生成的汇编程序转换为Linux的可重定位目标文件(*.o),然后gcc的静态链接器读入所有的目标文件和程序所依赖的库文件(*.a),将他们拼装为一个可执行的elf文件。以上所述是高级语言的静态编译链接的完整步骤,如图1-1,可以看出在最终的可执行程序加载之前,可执行文件已经包含了程序所需的所有代码和数据,加载器仅仅需要把这些代码和数据按照指定位置映射到内存就可以正常执行程序了。

 

 

 

1-1  静态编译步骤

 

静态链接是早期编写计算机程序的基本工作方式,它的引入在一定程度加快了软件开发的效率。试想一下,如果没有静态链接器,我们编写的高级语言代码是否可以在机器上执行呢?答案是肯定的!我们可以使用编译器将所有的高级语言代码直接翻译为完整的汇编程序,然后利用汇编器将之汇编为可执行程序,这样就不需要链接器的参与了,如图1-2所示。

 

1-2  直接编译步骤

 

但是这么做有很大的问题:首先,这么做极不利于代码的共享和复用,对于使用相同功能的软件,所有的源代码必须重新编写,即使有开源的代码也需要整合到工程中重新编译,但是大多数情况下人们愿意分享代码的接口而不是代码的实现,这种编译方式对代码实现的隐藏非常不利。另外,当我们的软件规模达到一定程度后,每个文件的代码都有成千上万行,若按照上述的编译方式进行工作,即使我们仅仅修改了工程下的一个代码文件的一个字符,编译器也需要把所有的文件的所有代码重新编译一遍,效率的低下可想而知。一旦引入链接器,这个问题可以迎刃而解,每次编译都会将已经修改的文件重新转化为一个目标文件,然后让链接器重新连接这些文件就可以了。这样做使得修改的文件得到正确编译,为修改的不需要编译,极大的节约了编译的时间,如图1-3所示。或许有人会疑惑链接器工作时不也是需要所有的文件吗?但是事实上,编译器的工作复杂程度远远大于链接器,因此链接器的引入是值得的。

 

 

1-3  静态链接器

 

事实上,静态链接的这种工作方式还是有很大的弊端的。首先,静态链接是在程序执行之前将程序所需要的所有数据和代码组装完毕存储在外设中,尤其是现在语言库庞大复杂的时候,静态链接生成的文件包含数兆字节的库数据和代码,而我们实际的代码可能只有几千字节。代码库的存在使得原本用户自身的程序变得异常庞大,不利于程序的共享和传输。更令人无奈的是这些代码库一般在用户的本地都有相应的备份,但是实际上我们却要为每个可执行程序保留一份库代码。其次,庞大的可执行文件在运行时不利于内存的合理利用,按照这种方式链接的程序在内存中存在大量的空间重复存储着每个可执行程序的库文件代码和数据。另外,对于大型系统的更新,静态链接器无法完成这个任务。因为用户最希望程序更新的时候只需要更新那些需要修改的文件即可,不要更新所有的可执行文件。但是静态链接做不到这一点,因为它需要将所有的目标文件重新链接才能保证可执行程序的正常执行。

鉴于以上缺点,现在的链接器基本上都是采用动态链接的方式完成,如图1-4所示。既然静态链接生成的可执行文件庞大臃肿,而且更新困难,于是动态链接器将真正的链接推迟到程序的加载时期或者运行时再进行,这样就不需要在磁盘中存储大量库文件的副本了。

 

1-4  生成共享对象

 

另外,由于动态链接的程序保留了所需库文件的“链接”,它知道自己所需要的库文件(*.so)的位置,在运行时只需要将对应的库文件和它进行绑定就可以了。所以程序更新时,如果程序的结构设计的合理,模块化程序高,我们只需要更新那些库文件就行,程序运行时会自动绑定最新的库文件内容。

动态链接器的出现完美的解决了静态链接的两大问题,但是动态链接器也有其本身的弱点:首先动态链接器把链接过程推迟到装载和运行时,这么做对程序的执行效率会有一定的影响,实际的数据显示性能损失在5%以下,即使这样我们还是愿意接受动态链接器的。其次动态链接器还有一个很大的弱点就是动态链接库版本控制的问题,一旦库文件的更新前后不兼容,那么可执行程序就有可能无法正确执行,这就是我们熟知的DLL Hell的问题。相比于这一点,静态链接器相对反而显示了它的优势,因为它将所有依赖的数据和代码全部保留,因此静态链接的程序在各种环境下执行的压力就会小很多。因此,编译环境一般会提供给用户两种链接方式以方便用户按照实际需要选择。

相比于静态链接器,动态链接器的实现相对复杂,动态链接器不仅要完成链接的工作,还需要生成地址无关的代码以及本身“自举”的过程。但是他们的本质目的是相同的,因此这里重点讨论静态连接器的实现方法和过程。图1-5展示了静态链接器的基本结构,显然,静态链接的第一步就是解析重定位目标文件(*.o)的结构,因此我们在构造静态链接器之前必须熟悉和静态链接相关的elf文件结构。

 

 

1-5  静态链接器结构

 

二、             ELF文件格式

ELF文件格式比较复杂,它定义了一种通用的文件格式,支持可重定位的文件、可执行文件、共享目标文件、核心转储文件的存储需求。但是对于静态连接器来说,它只关心可重定位文件和可执行文件的关键结构。静态链接库本质上是可重定位文件的一个集合,因此可以当作一组可重定位文件处理。

 

2-1  ELF文件结构

如图2-1所示,标识出了可重定位文件和可执行文件所涉及的所有结构。其中程序头表(PHT)是可执行文件独有的,重定位表段是可重定位文件独有的结构。右侧标识了对应结构一般的大小(N为非负整数)。下边结合链接器结构信息分别对ELF的具体结构进行叙述,详细的ELF文件数据结构信息可以参考Linux系统下/usr/include/elf.h文件。

2.1       ELF文件头

ELF文件头数据结构如下:

typedef struct
{
  unsigned char e_ident[EI_NIDENT]; 
  Elf32_Half    e_type;         
  Elf32_Half    e_machine;      
  Elf32_Word    e_version;      
  Elf32_Addr    e_entry;        
  Elf32_Off     e_phoff;        
  Elf32_Off     e_shoff;        
  Elf32_Word    e_flags;        
  Elf32_Half    e_ehsize;       
  Elf32_Half    e_phentsize;    
  Elf32_Half    e_phnum;        
  Elf32_Half    e_shentsize;    
  Elf32_Half    e_shnum;        
  Elf32_Half    e_shstrndx;     
} Elf32_Ehdr;

     ELF文件头结构中的比较关键的字段有:

 

1e_ident16位字符数组标识了该文件的格式等基本信息,一般取值为0x7F,ELF,1,1,1后跟着90

2e_entry:代码段中程序的入口虚拟地址。

3e_phoff:程序头表在elf文件的偏移。

4e_shoff:段表在elf文件的偏移。

5e_ehsize:elf文件头的大小(52)。

6e_phentsize:程序头表项的大小(32)。

7e_phnum:程序头表项的个数。

8e_shentsize:段表项的大小(40)。

9e_shnum:段表项的个数。

10e_shstrndx:段表字符串表所在段在段表中的下标。

2.2       段表

通过ELF文件头的e_shoff找到段表的位置,根据e_shentsizee_shnum扫描获得所有段表项,段表项数据结构定义如下:

 

 

typedef struct
{
  Elf32_Word    sh_name;
  Elf32_Word    sh_type;
  Elf32_Word    sh_flags;
  Elf32_Addr    sh_addr;
  Elf32_Off     sh_offset;
  Elf32_Word    sh_size;
  Elf32_Word    sh_link;
  Elf32_Word    sh_info;
  Elf32_Word    sh_addralign;
  Elf32_Word    sh_entsize;
} Elf32_Shdr;

其中关键字段有:

 

1sh_name:段名在段字符串表的索引,不关心名字设置为0。这里需要提醒的是,在解析段的名称时必须找到段表字符串表的位置,但是在解析段表时候,我们怎么知道段表字符串表对应段表项的位置呢?“幸好”ELF文件头定义了e_shstrndx,我们通过e_shoff+e_shstrndx*e_shentsize直接定位到段表字符串表对应段表项的位置,解析该段表项可以获得段表字符串表的首地址,这样其他的段的解析就能正常找到自己的名字了。

2sh_type:段的类型,用到的有SHT_PROGBITS:代码和数据、SHT_SYMTAB:符号表、SHT_STRTAB:串表、SHT_REL:重定位表段。

3sh_flags:段标志,用到的有SHF_WRITE:可写、SHF_ALLOC:执行时分配内存、SHF_EXECINSTR:可执行。

4sh_addr:段加载后的虚拟地址。

5sh_offset:段在文件中的偏移。

6sh_size:段大小。

7sh_link:重定位表段的使用的符号表的段下标,符号表段使用的字符串表的段下标

8sh_info:重定位表作用段的段表下标。;

9sh_addralign:对齐方式大小,应该能被sh_addr整除,一般是4,对于可执行文件的.text段一般是16

10sh_entsize:表类型段的项大小,对于符号表和重定位表分别为168

2.3       符号表

段表项中SHT_SYMTAB类型的段都是符号表,其中sh_entsize一般设置为16,标识符号表项大小。于是符号表项数=sh_size/sh_entsize,结合符号表段偏移sh_offset可以扫描当前符号表的所有表项,符号表项数据结构定义如下:

 

 

typedef struct
{
  Elf32_Word    st_name;
  Elf32_Addr    st_value;
  Elf32_Word    st_size;
  unsigned char st_info;
  unsigned char st_other;
  Elf32_Half    st_shndx;
} Elf32_Sym;

其中关键字段有:

 

1st_name:符号名在串表的偏移,ELF文件的串标可能有多个,如何确定哪个串表呢?“幸好”前边所述的sh_link记录了关联的符号表段下标,通过这个下标就能访问到对应的符号表的首地址,继而就能解析符号表项的名称字段了。

2st_value:这个字段有多重含义,静态链接只关心两个方面:1、在重定位文件中,记录符号相对所在段的偏移量(COMMON块中表示对齐属性,这里不考虑);2、在可执行文件中记录符号的虚拟地址。

3st_size:符号大小,double类型是8字节,0标识未知。

4st_info:4位标识符号类型:只关心STT_NOTYPE-外部符号、STT_OBJECT-数据、STT_FUNC-函数(STT_SECTION类型忽略不考虑);高4位标识符号绑定信息:这里只关心STB_GLOBAL类型。

5st_shndx:符号所在段在段表的下标,若是SHN_UNDEF标识外部符号。

2.4       重定位表

段表项中SHT_REL类型的段都是重定位表,其中sh_entsize一般设置为8,标识重定位表项大小。于是重定位表项数=sh_size/sh_entsize,结合重定位表段偏移sh_offset可以扫描当前重定位表的所有表项,重定位表项数据结构定义如下:

typedef struct
{
  Elf32_Word    r_offset;
  Elf32_Addr    r_info;
} Elf32_Rel;

 

 

关键字段有:

 

1r_offset:重定位的位置,记录重定位的字节相对被重定位段的偏移量。

2r_info:8位表示冲定位入口类型,其中R_386_32标识绝对地址修正,R_386_PC32标识相对地址修正具体修正方式在后边会详细介绍;高24位标识重定位入口符号在符号表的下标,重定位表引用的符号表在段表的sh_link字段已经给出符号表的段表下标。

2.5       程序头表

以上的结构定义了静态链接器所需的全部的静态数据和代码信息,但是可执行文件在运行时的信息也需要有相关结构进行记录,程序头表定义了数据和代码在内存中的映射方式。

通过ELF文件头的e_phoff找到程序头表的位置,根据e_phentsizee_phnum扫描获得所有程序头表项,程序头表项数据结构定义如下:

typedef struct
{
  Elf32_Word     p_type;
  Elf32_Off      p_offset;
  Elf32_Addr     p_vaddr;
  Elf32_Addr     p_paddr;
  Elf32_Word     p_filesz;
  Elf32_Word     p_memsz;
  Elf32_Word     p_flags;
  Elf32_Word     p_align;
 } Elf32_Phdr;

 

 

关键字段有:

1p_type:加载段的类型,这里只关心PT_LOAD类型。

2p_offset:加载段在文件的偏移。

3p_vaddr:段加载到内存的虚拟地址。

4p_paddr:段加载到内存的物理地址,一般和p_vaddr相等。

5p_filesz:加载段长度,.bss段为0

6p_memsz:申请内存中的长度,.bss段大于0

7p_flags:段标志,只关心PF_X-可执行,PF_R-只读,PF_W-可写。

8p_align:加载段对齐信息,一般为内存对齐大小4K,另外,关于对齐还有一个结论:p_vaddr%p_align=p_offset%p_align

了解了静态链接器所涉及的ELF文件信息后,就可以着手构造一个简洁的链接器了,按照前边介绍链接器的结构,下边分别阐述每部分功能的构造。

三、             信息收集

链接器进行链接的第一个步骤就是扫描输入的文件,解析文件包含的所有“有价值”的对链接服务的信息,利用这些信息进行后边的操作。信息收集阶段需要解析每个输入的可重定位文件的信息,其中最关键的是段表、符号表、重定位表,其他的数据结构都是为得到这三个重要的数据结构服务的。我们构造一个数据结构Elf_file用于存储每个ELF文件的关键信息,以方便后期链接时进行信息的查询。将所有输入文件的信息存储到一个Elf_file列表后,链接器就扫描该列表收集所有的文件的段信息和符号信息,至于重定位表信息由于已经保存在Elf_file对象中,并且基本不需要额外的处理,因此到重定位时直接操作即可,并不需要额外的信息收集操作。下面首先介绍一下Elf_file存储结构:

3.1       ELF文件信息结构

Elf_file类存储每个ELF输入文件的关键信息,其主要字段如下:

Class Elf_file
{
    Elf32_Ehdr                                 ehdr;        //文件头
    vector<Elf32_Phdr*>                        phdrTab;     //程序头表
    hash_map<string, Elf32_Shdr*,string_hash>  shdrTab;     //段表
    hash_map<string,Elf32_Sym*,string_hash>    symTab;      //符号表
    vector<RelItem*>                           relTab;      //重定位表
} ;

//重定位项结构
struct RelItem
{
    string         segName;        //重定位的目标段名
    Elf32_Rel*     rel;            //重定位信息
    string         relName;        //重定位符号名
};

 

 

借助头文件/usr/include/elf.hSTL可以方便构造一个存储ELF文件信息的结构,这里使用哈希表按名存储段表和符号表方便查询。另外我们把Elf32_Rel结构的内容抽取出来放在RelItem结构中,方便查询重定位信息。

此外,Elf_file类提供了一个方法巧妙利用c的库函数freadfseek定位读取文件信息,将关键的信息映射到指定的数据结构中,大致流程如下:

1.使用fopen打开文件,rewind将文件指针移动到文件开始处。

2.读取一个Elf32_Ehdr大小的数据,存储在ehdr中,得到ELF文件头。

3.检测e_type,若是可执行文件,准备读取程序头表。否则转到5

4. fseeke_phoff,读取e_phnumElf32_Phdr大小的程序头表项,加入列表phdrTab

5. fseeke_shoff+e_shentsize*e_shstrndx读取段表字符串表对应段表项fseeksh_offset处,读取sh_size大小的数据到缓冲区shstrtabData,得到段表字符串表。

6. fseeke_shoff,读取e_shnumElf32_Shdr大小的段表项,根据shstrtabData+sh_name取出段名和当前段表项插入哈希表shdrTab中,这里忽略哪些没有名称(无效)的段表项。

7.获取shdrTab[.strtab”]段表项,解析字符串表。一般情况下字符串表是符号表指定的(sh_link),而且名称一般都是.strtab。和步骤5类似,将字符串表存储在缓冲区strtabData中。

8.获取shdrTab[.symtab]段表项,解析符号表。一般情况下符号表是有重定位表指定的(sh_link),而且名称一般都是.symtabfseeksh_offset处,读取sh_size/sh_entsizeElf32_Sym大小的符号表项,利用strtabData+st_name取出符号名和当前符号表项插入到哈希表symTab,记录所有非空的符号项(STB_GLOBAL是全局引用的,但是局部的符号也需要重定位引用,因为这是静态连接器),并不仅仅考虑STB_GLOBAL类型符号

9.扫描所有段表项,查找类型为SHT_REL的段表项(重定位段),解析出sh_info得到被重定位的段在段表的下标,取出该段段名(一般是重定位表段名的后半段:如.rel.text)。解析出sh_link得到使用的符号表的段表下标(此处一般是.strtab的段下标),按照类似8的方式解析每个重定位表项,解析出r_info的高24位得到重定位符号在符号表的下标,取出符号名,构造一个RelItem添加到重定位表中,因此所有重定位表都被合并到一个列表中了。

虽然以上完成了所有数据信息的抽取,但是利用很多默认的常识,在操作上还是有点投机性。比较正当的做法应该是从扫描段表的所有重定位表段表项开始,解析出sh_link指定的符号表的段下标得到符号表段表项,在解析该段表项的sh_link指定的字符串表的段下标获得字符串表段表项,继而获得所需的字符串表缓冲区,再根据r_info获得重定位符号的名称。获取段名的方式也是类似,需要读取重定位表段表项的sh_info指定的段下标获得作用段段表项,继而利用e_shstrndx按照56的方式获得段名。显然这么做极大的浪费时间和空间,因此用常识约定提前获取数据可以提高文件解析的速度,降低解析的复杂度。当然,我们需要清楚的是静态链接器的核心是重定位表的解析。另外需要提及的是在对很多数据结构解析的过程中都需要按照下标访问哈希表,而哈希表是无序的数据结构,因此我们需要在解析的过程中预留一些结构保存哈希表中数据存储的顺序,以满足类似的访问需求,Elf_file中设置了一些属性来记录这些内容,上述代码中没有列出。

3.2       段信息结构

为了简化链接器的复杂程度,这里只关注于三种段的信息即:.text.data.bss,因此在扫描输入文件时需要对所有文件的这三种段进行记录整合。所涉及的数据结构如下:

//一个数据块
struct Block
{
    char *           data;        //段数据缓存
    unsigned int     offset;      //块偏移
    unsigned int     size;        //块大小
};
//将同名的段合并为一个列表
struct SegList
{
    unsigned int         baseAddr;        //分配基地址
    unsigned int         offset;          //合并后的文件偏移
    unsigned int         size;            //合并后大小
    unsigned int         begin;           //开始位置偏移
    vector<Elf_file*>    ownerList;       //拥有该段的文件序列
    vector<Block*>       blocks;          //记录合并后的数据块序
    
//每类段计算自己的段修正后位置
    void allocAddr(string name,unsigned intbase,unsigned int& off); 
    //根据提供的重定位信息重定位地址
    void relocAddr(unsigned int relAddr,unsigned char type,unsigned int symAddr); 
};
//段信息
hash_map<string,SegList*,string_hash>   segLists;    //所有合并段表序列

 

 

    来自不同文件的每种类型(相同段名)的段都会被合并在一个SegList的结构中,其中最关键的是ownerList字段记录了拥有该类型段的所有文件。在信息收集过程中仅仅是记录了所有段的关联信息,并未创建具体的数据块对象,因此保存世纪数据的blocks字段是没有数据的。在地址分配时,每个类型的段基址、大小、对齐都会计算出来,一般的,合并.text段是按照16字节对齐,其他的都是4字节对齐。另外数据块对象被创建,并且随时记录了数据块的具体偏移和大小,计算了每个分段的虚拟地址,这样重定位时就有具体的数据可以操作了。

 

3.3       符号引用信息结构

符号引用相关的数据结构如下:

//符号引用对象,每一次外部符号引用都会产生
struct SymLink
{
    string           name;            //引用的符号名
    Elf_file*        recv;            //引用符号文件
    Elf_file*        prov;            //提供符号的文件,符号未定义时必然是NULL
};
//所有符号引用信息,符号解析前存储未定义的符号prov字段为NULL
vector<SymLink*>    symLinks;
//所有符号定义信息recv字段NULL时标示该符号没有被任何文件引用,否则记录最后一次引用
vector<SymLink*>    symDef;

链接器使用两个列表存储符号的定义和引用信息,列表项记录着符号的名称、引用文件、定义文件。针对不同的列表他们的含义不同,对于symDef而言,prov就是符号的提供者,recvNULL时标识该符号没有被引用过,这里的引用是指在其他的可重定位文件中被全局使用。对于symLink而言,recv就是该符号的使用者,也是这个符号引用信息的来源,prov是需要解析才能知道来自哪个文件的定义。

因此构造这个数据结构的方法是,扫描输入文件,找到每个文件的符号表,由于之前解析了有意义的符号表,这里就关注于sh_shndx字段,若这个字段是STN_UNDEF,即说明这个符号是外部符号,将这个符号的信息构造为SymLink对象,recv字段记录当前文件,添加到symLinks列表中,否则就是定义的符号,prov字段记录当前文件,添加到symDef列表中。

四、             符号验证

再继续后续的工作之前,必须对符号的引用信息进行一个合法验证,因为对不合法的链接,后续的操作都没有实际意义。其实符号引用的验证很简单,大致描述如下:

4.1  符号定义验证

按照选择排序的二重遍历方式遍历symDef列表,两两比对符号名称信息,一旦出现同名同类型的符号定义项,即出现符号冲突错误。另外还需要寻找名称为”_start”的全局符号定义项,以保证程序有合法的入口地址,否则报错。

4.2  符号引用验证

按照外层遍历符号引用信息symLink列表,内存遍历symDef列表(只关心全局符号),核对两个符号项的名称和类型信息,若吻合,说明找到了符号定义,将symLink项的prov记录为symDef项的prov作为符号定义信息,同时用symLink项的recv刷新symDef项的recv,继续下一个符号的验证,否则继续找后面没有找过的符号定义项,直到没有匹配项为止,报告符号未定义错误。

只有符号验证完毕后没有任何错误,才能继续后边的操作,否则停止链接器的运行。

五、             地址空间分配

前边提及到段信息的数据结构对于空间分配是必不可少的,地址空间分配是使得每个需要组合的段都能运行时的虚拟地址,另外,符号表中记录了符号在所在段的偏移量,这样,运行时的对应符号的虚拟地址就能计算出来,从而可以进行后续的符号解析操作,更重要的是,地址分配时还需要考虑文件偏移是否符合要求,以保证在最终组装文件的时候能正确的定位输出关键的段数据。

参考图5-1,链接器按照同名的段进行地址分配,它的目的是将同类型的段进行合并。以图中内容为例,当前处理的是.text段,前边信息收集的时候,链接器已经扫描所有输入文件,发现a.ob.o含有.text段,于是分别将它们加入拥有者序列ownerList中。现在地址分配时,按照ownerList的顺序抽取这两个文件中的.text段的具体数据,构造Block对象,对象的文件偏移为当前的segList初始化偏移size(文件头+PHT)对齐后的大小。因此在扫描拥有者序列之前,文件偏移已经对齐,因此当前segList的虚拟地址也按照PHTp_align字段的模同余(模内存页大小,一般是0x1000)的原则计算出来了,而对齐前的数据也会保存下来,方便后期组装文件时填充对齐的间隙区域。这样输入段的虚拟地址就确定了,通过写回Elf_file对象,保存当前的虚拟地址,记录段的总大小。然后根据.text基址和大小确定下一个类型段的初始化偏移,直到将剩下的.data.bss段处理完成后结束。

 

 

 

5-1  段信息组织

六、             符号解析

空间分配完毕后,每个输入文件的需要加载的段中都记录了分配后的段虚拟地址,这样就可以根据段的虚拟地址计算每个符号的虚拟地址了,符号解析的内容就是解析符号表的每个符号,将符号的相对所在段的偏移地址转换为符号的虚拟地址,符号解析的过程如图6-1所示。

 

6-1  符号解析

 

对于symDef,必须先计算里边每个符号的虚拟地址,通过prov指定的符号定义文件,找到符号表项,按照字段st_shndx记录的段表项找到段基址sh_addr,按照字段st_value找到符号偏移,两者之和即符号虚拟地址。

对于symLink,由于记录了prov,所以只需要将recv指定的符号引用文件对应的符号项的st_value赋值为prov被引用的文件提供该符号的st_value即可,这样就完成了所有需要重定位的符号地址解析工作。

七、             重定位

前边提到,一般链接器只需要对全局的符号进行重定位(本地符号已经生成了符号无关代码),但是由于静态连接器的特殊性,必须对所有使用的符号进行重定位,因此,前边的符号解析工作都涵盖了不仅是全局符号的定义还处理了本地符号的解析,以保证每一个文件的链接时重定位都是正常的。

重定位就是将之前不确定的全局符号引用地址,或者由于模块合并需要重新修正的符号定义地址(全局和局部)进行正确修改,以保证程序数据的正确和指令的正常执行。重定位操作一般分为两大类:R_386_32R_386_PC32,分别叫做绝对地址修正和相对地址修正。前者是针对符号直接引用的,后者是针对函数调用的。

前边Elf_file对象涉及的RelItem结构记录了充分的重定位信息,成员segName记录重定位作用的段名用来找到重定位的作用数据在哪个segList中,成员relName记录重定位符号名用来通过符号表找到st_value即被引用符号的虚拟地址(符号解析后有效)以及符号的大小st_size(一般为4字节)rel->r_offset记录了需要重定位的位置相对于被作用段的偏移,具体如图7-1所示。

 

7-1  重定位

通过以上数据,按照segName指定的段和当前的文件对象,在段信息结构中检索需要处理的具体某个Block对象,方法是按照ownerList的顺序查询segList[segName]blocks列表),当ownerList[i]与当前文件对象相同时对应的blocks[i]即为所找,将Block的数据data读取出来作为重定位的载体,按照r_offset对数据进行重定位操作,操作的参数就是被引用符号的虚拟地址st_value,操作类型分两种,针对绝对地址修正,只需要把st_value作为新的值覆盖int*(segData+r_offset)的区域即可(针对4字节符号)。相对地址修正相对复杂,一般是jmp活着call指令的操作数,编译器生成时会生成操作数默认值(当前修正位置相对下条指令的偏移,一般是-4),修正后的值应该是st_value-(sh_addr+r_offset)-4,可以理解为在被修正的位置的基础上再加上一个被引用符号相对该位置的一个偏移,即最终的相对跳转地址偏移。这样之前构造的段信息数据块的数据就被重定位了,为接下来文件组装提供重定位后的原始数据信息。

八、             可执行文件生成

完成以上工作就可以组装可执行文件并输出了,这一步中输出的段中需要包含PHT,不需要输出重定位表(不存在重定位),参考图2-1。生成可执行文件共分为两大步,首先需要填充一个合法的Elf_file对象,即文件中的数结构及之间的关联都是符合ELF格式的;另外,由于Elf_file对象不包含具体的数据信息,所以输出的内容不仅仅是Elf_file对象,还有段信息结构中的数据缓冲。

8.1  可执行文件组装

链接器事先保存了一个Elf_file对象,名为exe。对exe的数据结构填充分为以下几个步骤进行:

1.填充文件头ehdr:e_ident是个16字节数组,可以使用4次整数赋值完成。其他固定字段值一般变化不大,固定的代码如下:

//e_ident
int*p_id=(int*)exe.ehdr.e_ident;
*p_id=0x464c457f;
p_id++;
*p_id=0x010101;
p_id++;
*p_id=0;
p_id++;
*p_id=0;
//其他固定值字段
exe.ehdr.e_type=ET_EXEC;
exe.ehdr.e_machine=EM_386;
exe.ehdr.e_version=EV_CURRENT;
exe.ehdr.e_flags=0;
exe.ehdr.e_ehsize=52;

 

其他的字段需要在后边生成具体段后才能知道确定值。

2.生成PhdrTab:程序头表共两项(把.bss合并到.data,表项字段中都是PT_LOAD类型,对齐大小0x1000,代码段标志位PF_R|PF_X,数据段标志位PF_W|PF_R.data.bss需要合并p_memsz是两者大小之和,p_filesz.data大小(.bss不占磁盘空间)。其他的信息全部来自段信息结构SegList中,于是e_phoff=52,e_phnum=2,e_phentsize=32。当前偏移为52+32*2,累加段数据大小(.text.data.bss)到当前偏移

3.生成段表ShdrTab:段表第一样是空项,反映在哈希表中为键值为空串的一条记录。.text.data.bss按序插入到段表中。除了.bss段类型为SHT_NOBITS其他的是SHT_PROGBITS,除了.text段对齐大小是16(值4),其他的都是4(值2,除了.text段类型标志是SHF_ALLOC|SHF_EXCINSTR其他的是SHF_ALLOC|SHF_WRITEsh_name默认为0

4.生成段.shstrtab:除了前边提到的段名之外,还需要添加额外的段名.shstrtab.strtab.symtab,将这些字符串合并到一个连续数组中,记录数组长度,并将字符串开始索引和字符串名映射成哈希表shstrIndex,方便最后按段名修改段表中sh_name字段为索引,保存缓冲区数据到exe。在段表添加一个表项,类型SHT_STRTAB,当前偏移,缓冲数组长度,e_shstrndx为当前索引。累加当前偏移,e_shoff为当前偏移,e_shnum=7,已有段(3)和固定段(4),e_shentsize=40,累加7个段表项大小大当前偏移。

5.生成段.symtab:添加段表项,类型SHT_SYMTAB,当前偏移,大小(所有符号数+1)*16sh_entsize=16,sh_link为下一个索引(因为.strtab.symtab之后)。先对符号表添加一个空符号项,将原本符号定义文件的符号项修改为当前文件exest_shndx后添加到符号表。程序入口_start的虚拟地址赋值给e_entry,累加符号表项个数*16到当前偏移。

6.生成段.strtab:添加段表项,类型SHT_STRTAB、当前偏移。按照类似4的方法,保存缓冲区数据到exe,构造索引哈希表strtabIndex

7.遍历符号表和段表,使用刚才的索引哈希表shstrtabIndexstrtabIndex更新sh_namest_name

到这里,目标文件信息结构已经填充完毕。

8.2  可执行文件输出

由于.text.data.bss三个段被设计在可执行文件中间部分,因此对exe这个Elf_file文件的输出需要分两部完成,主要流程如下:

1.输出exeehdrPhdr,这里简单使用fwrite输出即可。

2.按照段表信息结构输出对应的段数据,输出对象是blocks[i]->data,另外在输出每个SegList时注意beginoffset的间隙以及段内数据块之间的间隙,如果大小不为0需要用0进行填充(对于段内数据间隙,建议使用0x90填充,因为如果填充的是.text段,0x90NOP指令,保证代码的稳定性),例如对于段间空隙填充

char pad[1]={0};
int padnum=offset-begin;
while(padnum--)
    fwrite(pad,1,1,fp);//填充

3.最后输出exe的剩下所有信息:shstrtabShdrsymtabstrtab

4.使用Linux下的chmod +x命令使该文件取得执行权限。

九、             总结

至此,静态链接器的基本功能已经全部实现,可以看出,静态链接器并未想象中那番神秘,简单的说它本身就是对按照一定格式存储的文件进行处理,然后再按照一定格式将拼装好的文件输出而已,当然不可忽略的难点就是ELF文件数据结构中关键字段的联系和理解。其实这个链接器并不能实现在已有系统中实现链接的功能,因为该链接器对文件的格式要求比较简单,不支持动态链接库的内容,也不支持静态库的调用,并只关注于三个数据段,因此若要生成的文件能正确执行的话,必须有相关的编译器进行约定,生成该链接器所需的文件格式信息,这样才能达到链接执行的目的。

posted @ 2012-05-25 23:50  Florian  阅读(2203)  评论(5编辑  收藏  举报