程序员的自我修养——链接、装载与库.md

第一章 温故而知新

对于下面这些问题,你的脑子里能够马上反应出一个很清晰又很明确的答案吗?

  • 程序为什么要被编译器编译了之后才可以运行?

  • 编译器在把C语言程序转换成可以执行的机器码的过程中做了什么,怎么做的?

  • 最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们怎么存放的,怎么组织的?

  • #include 是什么意思?把stdio.h包含进来意味着什么?C语言库又是什么?它怎么实现的?

  • 不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM),以及不同的操作系统(Windows、Linux、UNIX、Solaris),最终编译出来的结果一样吗?为什么?

  • Hello World程序是怎么运行起来的?操作系统是怎么装载它的?它从哪儿开始执行,到哪儿结束?main函数之前发生了什么?main函数结束以后又发生了什么?

  • 如果没有操作系统,Hello World可以运行吗?如果要在一台没有操作系统的机器上运行Hello World需要什么?应该怎么实现?

  • printf是怎么实现的?它为什么可以有不定数量的参数?为什么它能够在终端上输出字符串?

  • Hello World程序在运行时,它在内存中是什么样子的?

其他计算机组成原理和操作系统复习略

第二章 编译和链接

Visual Studio等IDE一般都将编译和链接的过程一步完成,通常将这种编译和链接合并到一起的过程称为构建(Build)。

2.1 被隐藏了的过程

在Linux下,当我们使用GCC来编译Hello World程序时,只须使用最简单的命令(假设源代码文件名为hello.c):

`$gcc hello.c`

事实上,上述过程可以分解为4个步骤,分别是预处理(Prepressing)、 编译(Compilation)、汇编(Assembly)和链接(Linking),如图所示:

image-20211113205542339

2.1.1 预编译

首先是源代码文件hello.c和相关的头文件,如stdio.h等被预编译器cpp预编译成一个.i文件。对于C++程序来说,它的源代码文件的扩展名可能是.cpp或.cxx,头文件的扩展名可能是.hpp,而预编译后的文件扩展名是.ii。

命令如下(-E表示只进行预编译):

`$gcc –E hello.c –o hello.i`

`$cpp hello.c > hello.i`

预编译过程主要处理那些源代码文件中的以“#”开始的预编译指令。比 如 “#include”、“#define ”等,主要处理规则如下:

  • 将所有的 “#define ”删除,并且展开所有的宏定义。

  • 处理所有条件预编译指令,比如 “#if”、“#ifdef”、“#elif”、“#else”、“#endif”。

  • 处理 “#include”预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件。

  • 删除所有的注释“//”和“/* */”。

  • 添加行号和文件名标识,比如#2“hello.c”2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。

  • 保留所有的 #pragma 编译器指令,因为编译器须要使用它们。

    经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题。

2.1.2 编译

编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。下一节简单介绍,详见编译原理

命令如下:

`$gcc –S hello.i –o hello.s`

或从.c文件开始

`$gcc –S hello.c –o hello.s`

实际上gcc这个命令只是这些后台程序的包装,它会根据不同的参数要求去调用预编译译程序、汇编器、链接器。

2.1.3 汇编

汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。

命令如下:

`$as hello.s –o hello.o`

或从.c文件开始

`$gcc –c hello.c –o hello.o`

2.1.4 链接

本章后续介绍。命令略

2.2 编译器做了什么

从最直观的角度来讲,编译器就是将高级语言翻译成机器语言的一个工具。编译过程一般可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。整个过程如图所示:

image-20211113205630014

下面几节由以下C语言代码为例:

    array[index] = (index + 4) * (2 + 6);

2.2.1 词法分析

首先源代码程序被输入到扫描器(Scanner),扫描器运用一种类似于有限状态机(Finite State Machine)的算法将源代码的字符序列分割成一系列的记号(Token)

词法分析产生的记号一般可以分为如下几类:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(如加号、等号)。在识别记号的同时,扫描器也完成了其他工作。比如将标识符存放到符号表,将数字、字符串常量存放到文字表等,以备后面的步骤使用。

2.2.2 语法分析

接下来语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)。整个分析过程采用了上下文无关语法(Context-free Grammar)的分析手段。简单地讲,由语法分析器生成的语法树就是以表达式(Expression)为节点的树。语法树如图所示:

[图片上传失败...(image-a7d5f3-1648465105741)]

在语法分析的同时,很多运算符号的优先级和含义也被确定下来了。比如乘法表达式的优先级比加法高,而圆括号表达式的优先级比乘法高,等等。另外有些符号具有多重含义,比如星号*在C语言中可以表示乘法表达式,也可以表示对指针取内容的表达式,所以语法分析阶段必须对这些内容进行区分。如果出现了表达式不合法,比如各种括号不匹配、表达式中缺少操作符等,编译器就会报告语法分析阶段的错误。

2.2.3 语义分析

接下来进行的是语义分析,由语义分析器(Semantic Analyzer)来完成。语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。比如C语言里面两个指针做乘法运算是没有意义的,但是这个语句在语法上是合法的;比如同样一个指针和一个浮点数做乘法运算是否合法等。编译器所能分析的语义是静态语义(Static Semantic),所谓静态语义是指在编译期可以确定的语义,与之对应的动态语义(Dynamic Semantic)就是只有在运行期才能确定的语义。

静态语义通常包括声明和类型的匹配,类型的转换。比如当一个浮点型的表达式赋值给一个整型的表达式时,其中隐含了一个浮点型到整型转换的过程,语义分析过程中需要完成这个步骤。比如将一个浮点型赋值给一个指针的时候,语义分析程序会发现这个类型不匹配,编译器将会报错。动态语义一般指在运行期出现的语义相关的问题,比如将0作为除数是一个运行期语义错误。

经过语义分析阶段以后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点。上面描述的语法树在经过语义分析阶段以后成为如图所示的形式:

[图片上传失败...(image-3ad280-1648465105741)]

2.2.4 中间语言生成

现代的编译器有着很多层次的优化,往往在源代码级别会有一个优化过程。源码级优化器(Source Code Optimizer)会在源代码级别进行优化,在上例中,(2 + 6)这个表达式可以被优化掉,因为它的值在编译期就可以被确定。类似的还有很多其他复杂的优化过程,我们在这里就不详细描述了。优化后的语法树如图所示:

[图片上传失败...(image-f27702-1648465105741)]

我们看到(2 + 6)这个表达式被优化成8。其实直接在语法树上作优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码(Intermediate Code),它是语法树的顺序表示,其实它已经非常接近目标代码了。但是它一般跟目标机器和运行时环境是无关的,比如它不包含数据的尺寸、变量地址和寄存器的名字等。

中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端。

2.2.5 目标代码生成与优化

编译器后端主要包括代码生成器(Code Generator)目标代码优化器(Target Code Optimizer)

代码生成器将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等。

最后目标代码优化器对上述的目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等。

2.3 略

2.4 模块拼装——静态链接

人们把每个源代码模块独立地编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接 (Linking)。链接过程主要包括了地址和空间分配(Address and Storage Allocation)符号决议(Symbol Resolution)重定位(Relocation)等这些步骤。

每个模块的源代码文件(如.c)文件经过编译器编译成目标文件(Object File,一般扩展名为.o或.obj),目标文件和库(Library)一起链接形成最终可执行文件。而最常见的库就是运行时库(Runtime Library),它是支持程序运行的基本函数的集合。库其实是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。关于库本书的后面还会再详细分析。

第三章 目标文件里有什么

3.1 目标文件的格式

现在PC平台流行的可执行文件格式(Executable)主要是Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkable Format),它们都是COFF(Common file format)格式的变种。目标文件就是源代码编译后但未进行链接的那些中间文件(Windows的.obj和Linux下的.o),它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储。

动态链接库(DLL,Dynamic Linking Library)(Windows的.dll和Linux的.so)及静态链接库(Static Linking Library)(Windows的.lib和Linux的.a)文件都按照可执行文件格式存储。

image-20211115211918107

3.2 目标文件是什么样的

程序源代码编译后的机器指令经常被放在代码段(Code Section)里,代码段常见的名字有“.code”或“.text”;全局变量和局部静态变量数据经常放在数据段(Data Section),数据段的一般名字都叫“.data”。

image-20211115212122076

从图中可以看到,ELF文件的开头是一个“文件头”,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息,文件头还包括一个段表(Section Table),段表其实是一个描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息。

对照图1来看,一般C语言的编译后执行语句都编译成机器代码,保存在.text段;已初始化的全局变量和局部静态变量都保存在. data段;未初始化的全局变量和局部静态变量一般放在一个叫.“bss”的段里。bss段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间。

总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。

数据和指令分段的好处:

  • 一方面是当程序被装载后,数据和指令分别被映射到两个虚存区域。由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这样可以防止程序的指令被有意或无意地改写。

  • 另外一方面是对于现代的CPU来说,它们有着极为强大的缓存(Cache)体系。由于缓存在现代的计算机中地位非常重要,所以程序必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。

如何有好处?

  • 第三个原因,其实也是最重要的原因,就是当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只须要保存一份该程序的指令部分。对于指令这种只读的区域来说是这样,对于其他的只读数据也一样,比如很多程序里面带有的图标、图片、文本等资源也是属于可以共享的。当然每个副本进程的数据区域是不一样的,它们是进程私有的。
    

3.3 挖掘SimpleSection.o

/*
* SimpleSection.c
*
* Linux:
*   gcc -c SimpleSection.c
*
* Windows:
*   c1 SimpleSection.c /c /Za
*/
 
int printf( const char* format, ... );
 
int global_init_var = 84;
int global_uninit_var;
 
void func1( int i )
{
    printf( "%d\n", i );
}
 
int main(void)
{
    static int static_var = 85;
    static int static_var2;
 
    int a = 1;
    int b;
 
    func1( static_var + static_var2 + a + b);
 
    return a;
}

$ gcc –c SimpleSection.c(-c表示只编译不链接)

$ objdump -h SimpleSection.o

image-20211118210652899

image-20211118210707121

SimpleSection.o的段除了最基本的代码段、数据段和BSS段以外,还有3个段分别是只读数据段(.rodata)注释信息段(.comment)堆栈提示段(.note.GNU-stack)

重要的段的属性:段的长度(Size)段所在的位置(File Offset),“CONTENTS”表示该段在文件中存在。我们可以看到BSS段没有“CONTENTS”,表示它实际上在ELF文件中不存在内容。“.note.GNU-stack”段虽然 有“CONTENTS”,但它的长度为0,这是个很古怪的段,我们暂且忽略它,认为它在ELF文件中也不存在。

image-20211115212325298

3.3.1 代码段

$ objdump -s -d SimpleSection.o

image-20211118210554499

image-20211118210614116

3.3.2 数据段和只读数据段

“.rodata”段存放的是只读数据,一般是程序里面的只读变量(如const修饰的变量)和字符串常量。单独设立“.rodata”段有很多好处,不光是在语义上支持了C++的const关键字,而且操作系统在加载的时候可以 将“.rodata”段的属性映射成只读,这样对于这个段的任何修改操作都会作为非法操作处理,保证了程序的安全性。另外在某些嵌入式平台下,有些存储区域是采用只读存储器的,如ROM,这样将“.rodata”段放在该存储区域中就可以保证程序访问存储器的正确性。

另外值得一提的是,有时候编译器会把字符串常量放到“.data”段,而不会单独放在“.rodata”段。

3.3.3 BSS段

.bss段存放的是未初始化的全局变量和局部静态变量。

其实我们可以通过符号表(Symbol Table)(后面章节介绍符号表)看到,只有 static_var2 被存放在了.bss段,而 global_uninit_var 却没有被存放在任何段,只是一个未定义的“COMMON符号”。这其实是跟不同的语言与不同的编译器实现有关

3.3.4 其他段

image-20211115212352687

3.4 ELF文件结构描述

image-20211115212421499

3.4.1 文件头

image-20211118210802380

image-20211118210846945

以32位版本的文件头结构“Elf32_Ehdr”作为例,它的定义如下:

typedef struct {
    unsigned char e_ident[16];
    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;

image-20211119201801751

ELF魔数 从上面输出的结果可以看到,ELF的文件头中定义了ELF魔数文件机器字节长度数据存储方式版本运行平台ABI版本ELF重定位类型硬件平台硬件平台版本入口地址程序头入口和长度段表的位置和长度段的数量等。这些数值中有关描述ELF目标平台的部分,与我们常见的32位Intel的硬件平台基本上一样。

几乎所有的可执行文件格式的最开始的几个字节都是魔数。这种魔数用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载。

文件类型 e_type 成员表示ELF文件类型,即前面提到过的3种ELF文件类型,每个文件类型对应一个常量。系统通过这个常量来判断ELF的真正文件类型,而不是通过文件的扩展名。

机器类型 ELF文件格式被设计成可以在多个平台下使用。这并不表示同一个ELF文件可以在不同的平台下使用(就像java的字节码文件那样),而是表示不同平台下的ELF文件都遵循同一套ELF标准。 e_machine成员就表示该ELF文件的平台属性,比如3表示该ELF文件只能在Intel x86机器下使用。

3.4.2 段表

段表(Section Header Table)是ELF文件中除了文件头以外最重要的结构,它描述了ELF的各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。

image-20211119201910661

段表是一个以“Elf32_Shdr”结构体为元素的数组。数组元素的个数等于段的个数,每个“Elf32_Shdr”结构体对应一个段。“Elf32_Shdr”又被称为段描述符(Section Descriptor)。ELF段表的这个数组的第一个元素是无效的段描述符,它的类型 为“NULL”。

Elf32_Shdr 段描述符结构

    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;

Elf32_Shdr的各个成员的含义如表所示:

image-20211118210940170

3.4.3 重定位表

见下一章

3.4.4 字符串表

因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。

[图片上传失败...(image-22a163-1648465105741)]

那么偏移与它们对应的字符串如表所示。

[图片上传失败...(image-494fc5-1648465105741)]

一般字符串表在ELF文件中也以段的形式保存,常见的段名为".strtab”或“.shstrtab"。这两个字符串表分别为字符串表(String Table)和段表字符串表(Section Header String Table)。顾名思义,字符串表用来保存普通的字符串,比如符号的名字;段表字符串表用来保存段表中用到的字符串,最常见的就是段名(sh_name)。

3.5 链接的接口——符号

定义和引用:

比如目标文件B要用到了目标文件A中的函数“foo”,那么我们就称目标文件A定义(Define)了函数“foo”,称目标文件B引用(Reference)了目标文件A中的函数“foo”。在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)

链接过程的本质就是要把多个不同的目标文件之间相互“粘”到一起。

链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value)。

3.5.1 ELF符号表结构

Elf32_Sym的结构定义如下:

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;

3.5.2 特殊符号

当我们使用ld作为链接器来链接生产可执行文件时,它会为我们定义很多特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接声明并且引用它,我们称之为特殊符号。具体请参阅本书第7章的“链接过程控制”。

3.5.3 符号修饰与函数签名

函数签名(Function Signature)包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。函数签名用于识别不同的函数,就像签名用于识别不同的人一样,函数的名字只是函数签名的一部分。

在编译器及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名对应一个修饰后名称(Decorated Name)

3.5.4 extern “C”

extern “C”用法:

#ifdef __cplusplus
extern "C" {
#endif
 
void *memset (void *, int, size_t);
 
#ifdef __cplusplus
}
#endif

如果当前编译单元是C++代码,那么memset会在extern “C”里面被声明;如果是C代码,就直接声明。上面这段代码中的技巧几乎在所有的系统头文件里面都被用到。

3.5.5 弱符号与强符号

对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。

针对强弱符号的概念,链接器就会按如下规则处理与选择被多次定义的全局符号:

  • 不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号);如果有多个强符号定义,则链接器报符号重复定义错误。
  • 如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号。
  • 如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。比如目标文件A定义全局变量global为int型,占4个字节;目标文件B定义global为double型,占8个字节,那么目标文件A和B链接后,符号global占8个字节(尽量不要使用多个不同类型的弱符号,否则容易导致很难发现的程序错误)。

3.6 略

第四章 静态链接

/* a.c */
extern int shared;
 
int main()
{
    int a = 100;
    swap(&a, &shared);
}
/* b.c */
int shared = 1;
 
void swap(int *a, int *b)
{
    *a ^= *b ^= *a ^= *b;
}

4.1 空间与地址分配

这里谈到的空间分配只关注于虚拟地址空间的分配

4.1.1 按序叠加

直接将各个目标文件依次合并。但是这样做会造成一个问题,在有很多输入文件的情况下,输出文件将会有很多零散的段。比如一个规模稍大的应用程序可能会有数百个目标文件,如果每个目标文件都分别有.text段、.data段和.bss段,那最后的输出文件将会有成百上千个零散的段。这种做法非常浪费空间,因为每个段都须要有一定的地址和空间对齐要求,比如对于x86的硬件来说,段的装载地址和空间的对齐单位是页,也就是4096字节(关于地址和空间对齐,我们在后面还会有专门的章节详细介绍)。

image-20211119202017107

4.1.2 相似段合并

一个更实际的方法是将相同性质的段合并到一起,比如将所有输入文件的“.text”合并到输出文件的“.text”段,接着是“.data”段、“.bss”段等,如图所示:

image-20211119202054285

现在的链接器空间分配的策略基本上都采用上述方法中的第二种,使用这种方法的链接器一般都采用一种叫两步链接(Two-pass Linking)的方法:

  • 空间与地址分配 扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。
  • 符号解析与重定位 使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。

image-20211119202138962

VMA表示Virtual Memory Address,即虚拟地址,LMA表示Load Memory Address,即加载地址,正常情况下这两个值应该是一样的,但是在有些嵌入式系统中,特别是在那些程序放在ROM的系统中时,LMA和VMA是不相同的。

image-20211119202305587

链接前后的程序中所使用的地址已经是程序在进程中的虚拟地址,即我们关心上面各个段中的VMA(Virtual Memory Address)和Size,而忽略文件偏移(File off)。

为什么链接器要将可执行文件“ab”的“.text”分配到0x08048094、将“.data”分配0x08049108?而不是从虚拟空间的0地址开始分配呢?这涉及操作系统的进程虚拟地址空间的分配规则,在Linux下,ELF可执行文件默认从地址0x08048000开始分配。关于进程的虚拟地址分配等相关内容我们将在第6章“可执行文件的装载与进程”这一章进行详细的分析。

4.1.3 符号地址的确定

通过段内相对偏移可计算出虚拟地址

4.2 符号解析与重定位

4.2.1 重定位

$objdump -d a.o

image-20211119202332050

image-20211119202341840

当源代码“a.c”在被编译成目标文件时,编译器并不知道“shared”和“swap”的地址,因为它们定义在其他目标文件中。所以编译器就暂时把地址0看作是“shared”的地址,我们可以看到这条“mov”指令中,关于“shared”的地址部分为“0x00000000”。

image-20211119202415211

image-20211119202441587

跟前面“shared”一样,“0xFFFFFFFC”只是一个临时的假地址,因为在编译的时候,编器并不知道“swap”的真正地址。

编译器把这两条指令的地址部分暂时用地址“0x00000000”和“0xFFFFFFFC”代替着,把真正的地址计算工作留给了链接器。我们通过前面的空间与地址分配可以得知,链接器在完成地址和空间分配之后就已经可以确定所有符号的虚拟地址了,那么链接器就可以根据符号的地址对每个需要重定位的指令进行地位修正。我们用objdump来反汇编输出程序“ab”的代码段,可以看到main函数的两个重定位入口都已经被修正到正确的位置:

$objdump –d ab

结果...

4.2.2 重定位表

重定位表(Relocation Table)的结构专门用来保存这些与重定位相关的信息。

对于可重定位的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段,所以其实重定位表也可以叫重定位段,我们在这里统一称作重定位表。比如代码段“.text”如有要被重定位的地方,那么会有一个相对应叫“.rel.text”的段保存了代码段的重定位表;如果数据段“.data”有要被重定位的地方,就会有一个相对应叫“.rel.data”的段保存了数据段的重定位表。

$ objdump -r a.o

image-20211119202517007

image-20211119202532243

每个要被重定位的地方叫一个重定位入口(Relocation Entry),我们可以看到“a.o”里面有两个重定位入口。重定位入口的偏移(Offset)表示该入口在要被重定位的段中的位置,“RELOCATION RECORDS FOR [.text]”表示这个重定位表是代码段的重定位表

对于32位的Intel x86系列处理器来说,重定位表的结构是一个Elf32_Rel结构的数组。Elf32_Rel的定义如下:

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

[图片上传失败...(image-73d0a2-1648465105741)]

4.2.3 符号解析

$ readelf -s a.o

image-20211119202629934

“shared”和“swap”都是“UND”,即“undefined”未定义类型,这种未定义的符号都是因为该目标文件中有关于它们的重定位项。所以在链接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。

4.2.4 指令修正方式

4.3 COMMON块

多个符号定义类型不一致的情况:

  • 两个或两个以上强符号类型不一致;
  • 有一个强符号,其他都是弱符号,出现类型不一致;
  • 两个或两个以上弱符号类型不一致。

第一种情况是无须额外处理的,因为多个强符号定义本身就是非法的,链接器会报符号多重定义错误;链接器要处理的就是后两种情况。

编译器将未初始化的全局变量定义作为弱符号处理。比如符号global_uninit_var,它在符号表中的各个值为(使用readelf -s):

[图片上传失败...(image-8551c7-1648465105741)]

可以看到它是一个全局的数据对象,它的类型为 SHN_COMMON 类型,这是一个典型的弱符号。那么如果我们在另外一个文件中也定义了global_uninit_var 变量,且未初始化,它的类型为double,占8个字节,情况会怎么样呢?按照COMMON类型的链接规则,原则上讲最终链接后输出文件中, global_uninit_var 的大小以输入文件中最大的那个为准。即这两个文件链接后输出文件中 global_uninit_var 所占的空间为8个字节。

当然COMMON类型的链接规则是针对符号都是弱符号的情况,如果其中有一个符号为强符号,那么最终输出结果中的符号所占空间与强符号相同。值得注意的是,如果链接过程中有弱符号大小大于强符号,那么ld链接器会报如下警告:

`ld: warning: alignment 4 of symbol `global’ in a.o is smaller than 8 in b.o`

致需要COMMON机制的原因是编译器和链接器允许不同类型的弱符号存在,但最本质的原因还是链接器不支持符号类型,即链接器无法判断各个符号的类型是否一致。

==在目标文件中,编译器为什么不直接把未初始化的全局变量也当作未初始化的局部静态变量一样处理,为它在BSS段分配空间,而是将其标记为一个COMMON类型的变量?==

当编译器将一个编译单元编译成目标文件的时候,如果该编译单元包含了弱符号(未初始化的全局变量就是典型的弱符号),那么该弱符号最终所占空间的大小在此时是未知的,因为有可能其他编译单元中该符号所占的空间比本编译单元该符号所占的空间要大。所以编译器此时无法为该弱符号在BSS段分配空间,因为所须要空间的大小未知。但是链接器在链接过程中可以确定弱符号的大小,因为当链接器读取所有输入目标文件以后,任何一个弱符号的最终大小都可以确定了,所以它可以在最终输出文件的BSS段为其分配空间。所以总体来看,未初始化全局变量最终还是被放在BSS段的。

4.4 C++相关问题

4.4.1 重复代码消除

重复的代码保留下来的主要问题:

  • 空间浪费。可以想象一个有几百个编译单元的工程同时实例化了许多个模板,最后链接的时候必须将这些重复的代码消除掉,否则最终程序的大小肯定会膨胀得很厉害。
  • 地址较易出错。有可能两个指向同一个函数的指针会不相等。
  • 指令运行效率较低。因为现代的CPU都会对指令和数据进行缓存,如果同样一份指令有多份副本,那么指令Cache的命中率就会降低。

一个比较有效的做法就是将每个模板的实例代码都单独地存放在一个段里,每个段只包含一个模板实例。比如有个模板函数是 add(), 某个编译单元以int类型和float类型实例化了该模板函数,那么该编译单元的目标文件中就包含了两个该模板实例的段。为了简单起见,我们假设这两个段的名字分别叫 .temp.add 和 .temp.add。 这样,当别的编译单元也以int或float类型实例化该模板函数后,也会生成同样的名字,这样链接器在最终链接的时候可以区分这些相同的模板实例段,然后将它们合并入最后的代码段。

4.4.2 全局构造与析构

  • .init 该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在main函数被调用之前,Glibc的初始化部分安排执行这个段的中的代码。
  • .fini 该段保存着进程终止代码指令。因此,当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码。

这两个段.init和.fini的存在有着特别的目的,如果一个函数放到.init段,在main函数执行前系统就会执行它。同理,假如一个函数放到.fint段,在main函数返回后该函数就会被执行。利用这两个特性,C++的全局构造和析构函数就由此实现。我们将在第11章中作详细介绍。

4.4.3 C++与ABI

我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)。

4.5 静态库链接

当我们编译和链接一个普通C程序的时候,不仅要用到C语言库libc.a,而且还有其他一些辅助性质的目标文件和库。

image-20211120152937535

                                                            理想的静态库链接图

为什么静态运行库里面一个目标文件只包含一个函数?比如libc.a里面printf.o只有printf()函数、strlen.o只有strlen()函数,为什么要这样组织?

我们知道,链接器在链接静态库的时候是以目标文件为单位的。比如我们引用了静态库中的printf()函数,那么链接器就会把库中包含printf()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中。由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那些没有被用到的目标文件(函数)就不要链接到最终的输出文件中。

第五章 Windows PE/COFF

第六章 可执行文件的装载与进程

6.1 进程虚拟地址空间

把程序和进程的概念跟做菜相比较的话,那么程序就是菜谱,计算机的CPU就是人,相关的厨具则是计算机的其他硬件,整个炒菜的过程就是一个进程。

虚拟地址空间 (Virtual Address Space)的大小由计算机的硬件平台决定,具体地说是由CPU的位数决定的。硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小,比如32位的硬件平台决定了虚拟地址空间的地址为 0 到 2^32-1,即0x00000000~0xFFFFFFFF,也就是我们常说的4GB虚拟空间大小;

进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程。

默认情况下,Linux操作系统将进程的虚拟地址空间做了如图所示的分配。

image-20211122204646568

6.2 装载的方式

覆盖装入(Overlay)页映射(Paging)是两种很典型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。

6.2.1 覆盖装入

覆盖装入的方法把挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉。这个小的辅助代码就是所谓的覆盖管理器(Overlay Manager)

image-20211122204759022

由于模块A和模块B之间相互调用依赖关系,我们可以把模块A和模块B在内存中“相互覆盖”,即两个模块共享块内存区域。当main模块调用模块A时,覆盖管理器保证将模块A从文件中读入内存;当模块main调用模块B时,则覆盖管理器将模块B从文件中读入内存,由于这时模块A不会被使用,那么模块B可以装入到原来模块A所占用的内存空间。很明显,除了覆盖管理器,整个程序运行只需要1 536个字节,比原来的方案节省了256字节的空间。覆盖管理器本身往往很小,从数十字节到数百字节不等,一般都常驻内存。

在多个模块的情况下, 程序员需要手工将模块按照它们之间的调用依赖关系组织成树状结构。

按照下图的组织关系,模块main依赖于模块A和B,模块A依赖于C和 D;模块B依赖于E和F,则它们在内存中的覆盖方式如图中所示。

image-20211122204853494

值得注意的是,覆盖管理器需要保证两点。

  • 这个树状结构中从任何一个模块到树的根(也就是main)模块都叫调用路径。当该模块被调用时,整个调用路径上的模块必须都在内存中。比如程序正在模块E中执行代码,那么模块B和模块main必须都在内存中,以确保模块E执行完毕以后能够正确返回至模块B和模块main。
  • 禁止跨树间调用。任意一个模块不允许跨过树状结构进行调用。比如上面例子中,模块A不可以调用模块B、E、F;模块C不可以调用模块D、B、E、F等。因为覆盖管理器不能够保证跨树间的模块能够存在于内存中。不过很多时候可能两个子模块都需要依赖于某个模块,比如模块E和模块C都需要另外一个模块G,那么最方便的做法是将模块G并入到main模块中,这样G就在E和C的调用路径上了。

6.2.2 页映射

6.3 从操作系统角度看可执行文件的装载

6.3.1 进程的建立

创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:

  • 创建一个独立的虚拟地址空间。
  • 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
  • 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。

首先是创建虚拟地址空间。一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构,在i386 的Linux下,创建虚拟地址空间实际上只是分配一个页目录(Page Directory)就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。

读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。但是很明显的一点是,当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。

由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件(Image)

由于虚拟存储的页映射都是以页为单位的,在32位的Intel IA32下一般为4 096字节,所以32位ELF的对齐粒度为0x1000(4KB)。

image-20211122205149454

这种映射关系只是保存在操作系统内部的一个数据结构。Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area)

将CPU指令寄存器设置成可执行文件入口,启动运行。操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。这一步看似简单,实际上在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换。不过从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。还记得ELF文件头中保存有入口地址吗?没错,就是这个地址。

6.3.2 页错误

上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入到内存中。

虚拟存储的实现需要依靠硬件的支持,对于不同的CPU来说是不同的。 但是几乎所有的硬件都采用一个叫MMU(Memory Management Unit) 的部件来进行页映射,如图所示。

image-20211122205454451

CPU将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。这时候我们前面提到的装载过程的第二步建立的数据结构起到了很关键的作用,操作系统将查询这个数据结构,然后找到空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还回给进程,进程从刚才页错误的位置重新开始执行。

image-20211122205319256

6.4 进程虚存空间分布

6.4.1 ELF文件链接视图和执行视图

对于相同权限的段,把它们合并到一起当作一个段进行映射。比如有两个段分别叫“.text”和“.init”,它们包含的分别是程序的可执行代码和初始化代码,并且它们的权限相同,都是可读并且可执行的。假设.text为4 097字节,.init为512字节,这两个段分别映射的话就要占用三个页面,但是,如果将它们合并成一起映射的话只须占用两个页面,如图所示。

image-20211122205840664

image-20211122205913647

ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存“Segment”的信息。程序头表也是一个结构体数组,它的结构体如下:

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;

image-20211122205954258

对于“LOAD”类型的“Segment”来说,p_memsz的值不可以小于p_filesz,否则就是不符合常理的。但是,如果 p_memsz 的值大于 p_filesz 又是什么意思呢?如果 p_memsz 大于 p_filesz ,就表示该“Segment”在内存中所分配的空 间大小超过文件中实际的大小,这部分“多余”的部分则全部填充为“0”。这样做的好处是,我们在构造ELF可执行文件时不需要再额外设立BSS的“Segment”了

6.4.2 堆和栈

操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以分为如下几种VMA区域:

  • 代码VMA,权限只读、可执行;有映像文件。
  • 数据VMA,权限可读写、可执行;有映像文件。
  • 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。
  • 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展。

image-20211122210037698

6.4.3 堆的最大申请数量

malloc的最大申请数量具体的数值会受到操作系统版本、程序本身大小、用到的动态/共享库数量、大小、程序栈数量、大小等,甚至有可能每次运行的结果都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术(主要是出于安全考虑,防止程序受恶意攻击),使得进程的堆空间变小。关于进程的堆的相关内容,在本书的第4部分还会详细介绍。

6.4.4 段地址对齐

每个段分开映射这种对齐方式在文件段的内部会有很多内部碎片,浪费磁盘空间。整个可执行文件的三个段的总长度只有12 014字节,却占据了5个页,即20 480字节,空间使用率只有58.6%。

[图片上传失败...(image-5fc9a1-1648465105741)]

为了解决这种问题,有些UNIX系统采用了一个很取巧的办法,就是让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次。比如对于SEG0和SEG1的接壤部分的那个物理页,系统将它们映射两份到虚拟地址空间,一份为SEG0,另外一份为SEG1,其他的页都按照正常的页粒度进行映射。而且UNIX系统将ELF的文件头也看作是系统的一个段,将其映射到进程的地址空间,这样做的好处是进程中的某一段区域就是整个ELF文件的映像,对于一些须访问ELF文件头的操作(比如动态链接器就须读取ELF文件头)可以直接通过读写内存地址空间进行。从某种角度看,好像是整个ELF文件从文件最开始到某个点结束,被逻辑上分成了以4 096字节为单位的若干个块,每个块都被装载到物理内存中,对 于那些位于两个段中间的块,它们将会被映射两次。

image-20211122210141518

image-20211122210205848

6.4.5 进程栈初始化

一般情况下,是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中(也就是 VMA中的Stack VMA)。

6.5 Linux内核装载ELF过程简介

后续补充

第七章 动态链接

见https://www.yuque.com/hxfqg9/bin/ug9gx5#5dvaL

7.1 为什么要动态链接

静态链接使得不同的程序开发者和部门能够相对独立地开发和测试自己的程序模块,从某种意义上促进了程序开发的效率,原先限制程序的规模也随之扩大。但缺点也逐步暴露出来,比如浪费内存和磁盘空间、模块更新困难等问题,使得人们不得不寻找一种更好的方式来组织程序的模块。

内存和磁盘空间

在静态链接中,C语言静态库是很典型的浪费空间的例子,还有其他数以千计的库如果都需要静态链接,那么空间浪费无法想象。

程序开发和发布

如果程序都使用静态链接,那么通过网络来更新程序将会非常不便,因为一旦程序任何位置的一个小改动,都会导致整个程序重新下载。

动态链接

把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking)的基本思想。

在内存中共享一个目标文件模块的好处不仅仅是节省内存,它还可以减少物理页面的换入换出,也可以增加CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上。

程序可扩展性和兼容性

动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点就是后来被人们用来制作程序的插件(Plug-in)

动态链接的基本实现

从本质上讲,普通可执行程序和动态链接库中都包含指令和数据,这一点没有区别。在使用动态链接库的情况下,程序本身被分为了程序主要模块(Program1)和动态链接库(Lib.so),但实际上它们都可以看作是整个程序的一个模块,所以当我们提到程序模块时可以指程序主模块也可以指动态链接库。

7.2 简单的动态链接例子

[图片上传失败...(image-822e60-1648465105741)]

图中有一个步骤与静态链接不一样,那就是Program1.o被连接成可执行文件的这一步。在静态链接中,这一步链接过程会把Program1.o和Lib.o链接到一起,并且产生输出可执行文件Program1。但是在这里,Lib.o没有被链接进来,链接的输入目标文件只有Program1.o。

关于模块(Module)

在静态链接时,整个程序最终只有一个可执行文件,它是一个不可以分割的整体;但是在动态链接下,一个程序被分成了若干个文件,有程序的主要部分,即可执行文件(Program1)和程序所依赖的共享对象 (Lib.so),很多时候我们也把这些部分称为模块,即动态链接下的可执行文件和共享对象都可以看作是程序的一个模块。

当程序模块Program1.c被编译成为Program1.o时,编译器还不不知道 foobar() 函数的地址,这个内容我们已在静态链接中解释过了。当链接器将Program1.o链接成可执行文件时,这时候链接器必须确定Program1.o中所引用的 foobar() 函数的性质。如果foobar() 是一个定义与其他静态目标模块中的函数,那么链接器将会按照静态链接的规则,将Program1.o中的foobar地址引用重定位;如果foobar() 是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。

那么这里就有个问题,链接器如何知道foobar的引用是一个静态符号还是一个动态符号?这实际上就是我们要用到Lib.so的原因。Lib.so中保存了完整的符号信息(因为运行时进行动态链接还须使用符号信息),把Lib.so也作为链接的输入文件之一,链接器在解析符号时就可以知道:foobar是一个定义在Lib.so的动态符号。这样链接器就可以对foobar的引用做特殊的处理,使它成为一个对动态符号的引用。

动态链接程序运行时地址空间分布

查看进程的虚拟地址空间分布:

[图片上传失败...(image-70abb-1648465105741)]

[图片上传失败...(image-b38054-1648465105741)]

整个进程虚拟地址空间中,多出了几个文件的映射。Lib.so与Program1一样,它们都是被操作系统用同样的方法映射至进程的虚拟地址空间,只是它们占据的虚拟地址和长度不同。Program1除了使用Lib.so以外,它还用到了动态链接形式的C语言运行库libc-2.6.1.so。另外还有一个很值得关注的共享对象就是ld-2.6.so,它实际上是Linux下的动态链接器。动态链接器与普通共享对象一样被映射到了进程的地址空 间,在系统开始运行Program1之前,首先会把控制权交给动态链接器, 由它完成所有的动态链接工作以后再把控制权交给Program1,然后开始执行。

共享对象的最终装载地址在编译时是不确定的, 而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。

7.3 地址无关代码

7.3.1 固定装载地址的困扰

静态共享库的做法就是将程序的各种模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间。

静态共享库的目标地址导致了很多问题,除了上面提到的地址冲突的问题,静态共享库的升级也很成问题,因为升级后的共享库必须保持共享库中全局函数和变量地址的不变,如果应用程序在链接时已经绑定了这些地址,一旦更改,就必须重新链接应用程序,否则会引起应用程序的崩溃。

为了解决这个模块装载地址固定的问题,我们设想是否可以让共享对象在任意地址加载?这个问题另一种表述方法就是:共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。与此不同的是,可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的地址,比如Linux下一般都是0x08040000,Windows下一般都是0x0040000。

7.3.2 装载时重定位

我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置(Rebasing),我们在后面将会有专门章节分析基址重置。

7.3.3 地址无关代码

  • 第一种是模块内部的函数调用、跳转等。
  • 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
  • 第三种是模块外部的函数调用、跳转等。
  • 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。

image-20211205113143966

类型一 模块内部调用或跳转

这种指令是不需要重定位的。

类型二 模块内部数据访问

相对寻址

类型三 模块间数据访问

ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用,它的基本机制如图所示。

image-20211205132559565

类型四 模块间调用、跳转

7.6 动态链接的步骤和实现

动态链接的步骤基本上分为3步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化。

7.6.1 动态链接器自举

7.6.2 装载共享对象

完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件所依赖的共享对象,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和“.dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。 如果这个ELF共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止,当然链接器可以有不同的装载顺序,如果我们把依赖关系看作一个图的话,那么这个装载过程就是一个图的遍历过程,链接器可能会使用深度优先或者广度优先或者其他的顺序来遍历整个图,这取决于链接器,比较常见的算法一般都是广度优先的。

符号的优先级

这种一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象又被称为共享对象全局符号介入(Global Symbol Interpose)

关于全局符号介入这个问题,Linux定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。

全局符号介入与地址无关代码

为了提高模块内部函数调用的效率,有一个办法是把 bar() 函数变成编译单元私有函数,即使用“static”关键字定义 bar() 函数,这种情况下,编译器要确定 bar() 函数不被其他模块覆盖,就可以使用第一类的方法,即模块内部调用指令,可以加快函数的调用速度。

7.6.3 重定位和初始化

第8章 Linux共享库的组织

libname.so.x.y.z

最前面使用前缀“lib”、中间是库的名字和后缀“.so”,最后面跟着的是三个数字组成的版本号。“x”表示主版本号(Major Version Number),“y”表示次版本号(Minor Version Number),“z”表示发布版本号(Release Version Number)。三个版本号的含义不一样。

  • 主版本号表示库的重大升级,不同主版本号的库之间是不兼容的,依赖于旧的主版本号的程序需要改动相应的部分,并且重新编译,才可以在新版的共享库中运行;或者,系统必须保留旧版的共享库,使得那些依赖于旧版共享库的程序能够正常运行。
  • 次版本号表示库的增量升级,即增加一些新的接口符号,且保持原来的符号不变。在主版本号相同的情况下,高的次版本号的库向后兼容低的次版本号的库。
  • 发布版本号表示库的一些错误的修正、性能的改进等,并不添加任何新的接口,也不对接口进行更改。

你可能感兴趣的:(程序员的自我修养——链接、装载与库.md)