《程序员的自我修养:链接、装载与库》

发布于 2020-08-05 02:09:54   阅读量 33  点赞 0  


[第一部分] 简介

1. 简介

北桥南桥

 计算机中,三个最重要的部件为:中央处理器 CPU、内存、I/O 控制芯片。

北桥芯片是为了协调 CPU、内存和高速图形设备间的频率,以便它们之间可以高速地交换数据。

 于此同时,由于北桥芯片的速度非常高,所有相对低速的设备若直接连接到北桥,北桥同时处理高速与低速设备,将导致设计十分复杂,于是又有了南桥芯片。南桥芯片用于专门处理低速设备,将它们汇总后连接到北桥上。如磁盘、USB、键盘、鼠标等都是连接在南桥上。


应用程序编程接口

应用程序编程接口(Application Programming Interface)即俗称的 API,由运行库提供,不同的运行库提供不同的 API。

 而运行库则使用操作系统提供的系统调用接口(System call Interface),系统调用接口往往以软件中断的方式提供实现。

 操作系统内裤对于硬件层来说是硬件接口的使用者,而硬件是硬件接口的定义者,硬件的接口定义决定了操作系统该使用的内核。


CPU 分配

 多任务系统模式用于充分利用 CPU 算力。操作系统接管了所有的硬件资源,所有的应用程序都以进程的方式运行在比操作系统权限更低的级别。CPU 由操作系统同统一分配,每个进程根据进程优先级获取 CPU;但是,若运行超出了一定时间,则操作系统会暂停该进程,将资源让给其他等待运行的进程。

 这种 CPU 的分配方式称为抢占式,操作系统可以强制剥夺 CPU 资源并进行重分配(无需等程序主动让出)。当操作系统分配给每个进程的时间都很短,从而造成多个进程同时运行的假象。


分段与分页

 分段与分页是一种存储器管理技术。

分段方法用以解决:地址空间不隔离、程序运行地址不确的问题。其基本思想是将一段程序所需内存大小的虚拟空间映射到某个地址空间,即引入了虚拟内存。

 而分页用于解决内存使用效率低的问题。分页提供了更加细粒度的内存分割与映射方法,提高了程序的局部性,减少了磁盘与内存间无必要的数据交换操作。其基本思想是将地址空间等分地分为固定大小的页(页的大小由硬件决定)。将进程使用的虚拟内存段按页分割,将常用的数据与代码载入内存中,不常用的保存在磁盘中,需要的时候再从磁盘读取。

 以页为单位存取与交换数据非常方便,因硬件本身就支持以页的方式操作。

 虚拟存储需要名为MMU的硬件来进行页映射。页映射时 CPU 发出的是Virtual Address(即我们程序看到的虚拟地址),经过MMU转换之后才变成Physical Address


线程的数据

 线程可以访问进程内存里的所有数据,甚至包括其他线程的堆栈(若知道其他线程的堆栈地址),但实际中线程也有自己的私有存储空间,包括:

  • 栈(尽管并非完全无法被其他线程访问,但一般可认为为私有的)

  • 线程局部存储(Thread Local Storage,TLS),是操作系统为线程提供的私有空间

  • 寄存器,寄存器为执行流的基本数据,因此为线程私有

 从程序员的角度,线程拥有的所有数据有:


线程抢占

抢占为线程在用尽时间片后被操作系统强制剥夺继续执行的权利,从而进入就绪的过程。

 不可抢占线程:早期操作系统中,线程是不可抢占的,即线程必须主动进入就绪状态,而非依靠时间片用尽来被强制进入,若线程始终拒绝进入就绪状态,且不进行任何等待,则其他线程无法执行。  不可抢占线程主动进入就绪只有以下两种情况:

  • 线程主动放弃时间片

  • 线程视图等待某事件(I/O 等)

 而现如今的线程调度系统,都是抢占式的。


线程安全

信号量

信号量一种锁,用以限制使用资源的线程数量。如:一个初始值为 N 的信号量允许 N 个线程并发访问,线程获取资源的时候首先获取信号量,并:

  • 若信号量为 0,则进入等待,否则继续执行

  • 将信号量减 1,并获取资源

  • 使用完资源后,信号量加 1

  • 唤醒一个等待中的线程

 二元信号量是最简单的一种锁,只有两种状态:占用与非占用,适合只能被一个线程访问的资源。


互斥量

 互斥量(Mutex)与二元信号量类似,资源仅同时允许一个线程获取资源;但与信号量不同的是,信号量在整个系统中可以被任意线程释放。

 也即是说,同一个信号量可以被系统中的一个线程获取后由另一个线程释放,但互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁。


临界区

 临界区是比互斥量更加严格的同步手段。

 临界区和信号量、互斥量的区别在于,互斥量和信号量在系统中任意进程都是可见的,也就是说,当一个进程创建了一个互斥锁,其他进程试图去获取这个锁是合法的。而临界区的作用范围仅限于本进程,其他线程无法获取该锁。除此之外,临界区具有和互斥量相同的性质。


读写锁

 读写锁致力于更加特定的场合。

 对于同一个锁,读写锁有两种获取方式:共享的独占的。线程获取锁时,有以下几种状态:

读写锁状态 以共享方式获取 以独占方式获取
自由 成功 成功
共享 成功 等待
独占 等待 等待


可重入

重入指的是一个函数调用没有执行完成,而由于外部因素或内部调用,又一次进入该函数执行。一个函数要被重入,只有两种情况:

  1. 多个线程同时执行这个函数

  2. 函数自身调用自身

 一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后果。一个函数是可重入的,需要满足以下条件:

  • 不用任何(局部)静态或全局非const变量

  • 不返回任何(局部)静态或全局非const变量的指针

  • 仅依赖于调用方提供的参数

  • 不依赖任何单个资源的锁(mutex等)

  • 不调用任何不可重入的函数

 可重入是线程安全的保障,一个可重入的函数可以在多线程的环境下放心使用。


过度优化

 即使合理地使用了锁,也不一定能保证线程安全,这是源于落后的编译器技术已经无法满足日益增长的并发需求。比如,编译器在进行优化的时候,可能会为了效率交换毫不相干的两条相邻指令的执行顺序。

 对于编译器引起的线程不安全情况,我们可以使用volatile关键字阻止过度优化,volatile可以确保两件事:

  1. 阻止编译器为提高速度将一个变量缓存到寄存器内而不回写(即将变量缓存在线程本地的工作内存中,详见[线程资源共享]);

  2. 阻止编译器调整操作volatitle变量的指令顺序


三种多线程模型

 大多数操作系统都在内核里提供线程支持,内核线程同样由多处理器或调度来实现并发。而用户实际使用的用户态线程不一定在操作系统内核里对应相等数量的内核线程。下面介绍用户态多线程库的三种实现模型:

1. 一对一模型

 对于一对一模型来说,一个用户使用的线程唯一对应一个内核使用的线程(反过来则不一定,一个内核里的线程不一定有对应的用户态线程存在)。

 对于直接支持线程的系统,一对一模型是最为简单的模型,且一般直接使用系统调用创建的线程为一对一线程。

 这样,用户线程就具有了与内核线程一致的优点,线程间的并发是真正的并发。一个线程阻塞时,其他线程的执行不会受影响。此外,一对一的模型在多处理器系统上有更好的表现。

 但与此同时,一对一线程有两个缺点:

  • 操作系统会限制内核线程的数量,故一对一线程会让用户的线程数量受到限制

  • 内核线程调度时,上下文切换的开销较大,导致用户线程执行效率低下


2. 多对一模型

 多对一模型将多个用户线程映射到一个内核线程上,线程间的切换由用户态的代码来进行,因此多对一模型的线程切换相较于一对一的速度更快。

 多对一模型的问题是,若其中一个用户线程阻塞,则所有线程将无法执行,因为此时内核里的线程也随之阻塞了。此外,在多处理器系统上,处理器的增多对多对一模型的线程性能不会有明显的帮助。

 但同时,多对一模型的好处在于高效的上下文切换与近乎无限制的线程数量。


3. 多对多模型

 多对多模型结合了一对一模型和多对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上。

 多对多模型中,一个用户线程的阻塞不会使得所有用户线程阻塞,因为此时还有别的内核线程可以被调度执行。此外,多对多模型对线程的数量也没有限制,在多处理器系统上,多对多线程也能得到一定的性能提升,不过提升的幅度不如一对一。



[第二部分] 静态链接

一、编译和链接

 一般的 IDE,会将编译与连接操作合并到一起,称为构建。


1. 编译程序

 在 Linux 环境下,只需使用 GCC 就可编译程序:

$gcc hello.c    # 编译程序
$./a.out        # 运行输出程序

 而事实上,上述的过程可以分为 4 个步骤:预处理编译汇编链接

编译操作特指一个程序的编译,包括预处理、编译、汇编、链接的组合过程。

而编译则指编译操作中单独的编译环节;同时,编译也可指预编译、编译与汇编的组合过程,即通过编译,产生未链接的目标文件。


① 预编译(输出.i)

 预编译(可以理解为预处理)是指,使用预编译器将源代码文件和相关的头文件,预编译成一个文件。预编译主要处理在源代码文件中以#开头的预编译指令,输出一个与源代码文件等效的代码文件。

 对于 C/C++ 程序的区别,有:

  1. C 程序的源代码文件拓展名为.c,头文件是.h,预编译后的文件拓展名是.i

  2. C++ 程序的源代码文件拓展名为.cpp.cxx,头文件可能是.hpp,而预编译后的文件拓展名是.ii

 进行预编译可以通过:

  1. gcc编译器与选项-E

    $gcc -E hello.c -o hello.i
    
  2. cpp预编译器:

    $cpp hello.c > hello.i
    

 预编译的工作如下:

  1. 删除#define语句,并展开所有的宏定义;

  2. 处理条件预编译指令如:#if#ifdef#elif#else#endif

  3. 处理#include指令,将被包含的文件插入到该预编译指令的位置,这个过程是递归进行的(被包含的文件可能包含其他文件);

  4. 删除所有注释;

  5. 添加行号和文件名标识,以便编译时产生调试的行号信息及用于编译错误时产生包含行号的警告;

  6. 保留所有#pragma编译器指令,用于编译时使用

经过预编译的.i文件不包含任何宏定义,因所有的宏都已被展开,且包含的文件也被插入到.i文件中


② 编译(输出.s)

编译就是将预处理后的文件进行一系列词法分析、语法分析、语义分析及优化,产生相应的汇编代码文件

 要将.i编译为.s,可以用gcc编译器与选项-S

$gcc -S hello.i -o hello.s

gcc可以将预编译与编译两个步骤合并为一个步骤,即直接将源代码文件转换为.s文件(通过调用cc1程序):

$gcc -S hello.c -o hello.s

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


③ 汇编(输出.o)

汇编是将汇编代码变成机器可以直接执行的机器指令,输出目标文件.o。每个汇编语句几乎都对应一条机器指令,故汇编过程相对编译要简单,无需判断复杂的语法语义、无需进行指令的优化,只需根据汇编指令与机器指令的对照表一一翻译即可。

 将汇编代码文件(.s)汇编为目标文件(.o)可以通过:

  1. gcc编译器与选项-c

    $gcc -c hello.s -o hello.o
    
  2. as汇编器:

    $as hello.s -o hello.o
    

 也可以使用gcc编译器,直接由源代码文件输出目标文件(不进行链接):

$gcc -c hello.c -o hello.o


④ 链接

 链接是指将多个目标文件进行组合,并输出一个可执行文件的过程。


2. 编译器的工作

 从直观的角度来看,编译器就是将高级语言翻译成机器语言的工具。编译器的工作过程一般可以分为六步:扫描语法分析语义分析源代码优化代码生成目标代码优化

 以以下 C 代码为例,列举编译各过程:

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


① 词法分析

 首先将源代码程序输入到 扫描器(Scanner) 进行词法分析,扫描器运行有限状态机将源代码的字符序列分割成一系列记号(Tokens)

 词法分析器产生的记号一般可分为四种:关键字标识符字面量特殊符号(运算符等)。

 在识别记号的同时,扫描器还会进行其他工作:将标识符放到符号表、将字面量放到文字表等。以备后续的步骤使用。

 程序lex用实现词法扫描,它会按照用户之前描述好的词法规则将输入的字符串分割成一个个记号。因为这个程的序存在,编译器开发者只需改变词法规则,而无需为每个编译器开发独立的词法扫描器。

将源代码字符序列分割成记号(关键字、标识符、字面量、特殊符号)序列。


② 语法分析

语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生以表达式为节点的 语法树(Syntax Tree)。这个过程采用了上下文无关语法的分析手段。

 进行语法分析时,语法分析器确定各种运算符的 优先级含义(即区分符号的多种含义,如*。若出现了表达式不合法,如括号不匹配、操作符确实等,编译器就会报告语法分析阶段的错误。

 正如词法分析器一样,语法分析器也有现成的工具yacc,它与lex类似,可以根据用户给定的语法规则对输入的记号序列进行解析,从而构建一棵语法树。对于不同的编程语言,编译器的开发者只需改变语法规则,而无需编写一个新的语法分析器。

确定各种符号的优先级与含义,基于输入的记号序列生成语法树。


③ 语义分析

 接下来 语义分析器(Semantic Analyzer) 进行语义分析。

 语法分析仅完成了对表达式语法层面的分析,但并不了解这个语句是否真的有意义。编译器所能分析的语义是 静态语义,即在编译期可以确定的语义;与之对应的 动态语义,则只有在运行时才能确定。

静态语义通常包括声明和类型的匹配,类型的转换。经过语义分析阶段之后,语法树的表达式都被标识了类型,若有些类型需要做隐式类型转换,语义分析程序会在语法树中插入相应的转换节点。

标记语法树中表达式的类型,并进行隐式类型转换。


④ 源代码优化

 现代编译器往往会在源代码级别进行优化,源代码优化器(Source Code Optimizer) 用来进行在源代码层面的优化。

 直接在语法树上进行优化比较困难,故源代码优化器通常将整个语法树转换为 中间代码(Intermediate Code)。中间代码是语法树的顺序标识,它非常接近目标代码,但一般跟目标机器与运行时环境无关。

 中间代码多种类型,在不同的编译器中有不同的形式,常见的有:三地址码,P-代码。以三地址码为例,最基本的三地址码形式为:

x = y operator z

 表示将yz经过operator运算后,赋值给x。上述例子中的语法树可以被翻译为三地址码:

t1 = 2 + 6
t2 = index + 4
t3 = t2 * t1
array[index] = t3

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

将代码转换为中间代码,进行源代码优化。


④ 目标代码生成与优化

 接下来的工作为编译器后端处理中间代码。编译器后端主要包括代码生成器(Code Generator)目标代码优化器(Target Code Optimizer)

 首先,代码生成器将中间代码转换成目标机器的汇编代码,这个过程依赖于目标机器

 接着目标代码优化器对上述目标代码进行优化(如选择合适的寻址方式、使用位移代替乘法、删除多余指令等)。

 经过扫描、语法分析、语义分析、源代码优化、目标代码生成与优化,源代码文件就变为汇编代码文件;再经过汇编器的处理,就能得到目标代码(机器代码)。

也可以将汇编器看作编译器的一部分,认为编译器接收源代码文件输入,输出目标文件。

 代码中使用的其他模块的全局变量与函数在运行时的绝对地址,只有在最终链接时才能确定。所以编译器将源代码文件编译成未链接的目标文件,然后由链接器将目标文件进行链接,形成可执行文件。



3. 链接

 最常见的 C/C++ 模块间的通信方式有两种:①模块间的变量访问②模块间的函数调用,这两种通信方式可以归结为模块间符号的引用。

 模块间的通信方式类似于拼版图,定义符号的模块多出一块区域,使用符号的模块则恰好缺少那一块,而拼接的过程则称为链接。

 链接的主要内容就是处理好各模块间的相互引用,使得各模块之间能够正确斜街。


① 静态链接

 链接的过程主要包括 地址和空间分配符号决议重定位等。

符号决议也叫符号绑定,但从细分的角度,“决议”更倾向于静态链接、而“绑定”更倾向于动态链接。

 每个模块的源代码文件经过编译,产生 目标文件(Object File),目标文件和 库(Library) 一起链接最终形成可执行文件。

库是一组目标文件的包,即一些最常用的代码编译成目标文件后打包存放。最常见的库是运行时库。

 通过链接器,编程时可以直接引用其他模块的函数和全局变量而无需知道它们的地址。因为编译器编译文件时,若遇到未知地址的符号,会暂时将该符号的目标地址搁置;当链接器在链接的时候,会根据引用的符号,自动去相应的模块中查找地址,然后将源模块中的目标地址进行修改。这个地址修正的过程称为 重定位(Relocation),每个要被修正的地方叫做一个 重定位入口



二、目标文件

 编译器编译目标文件后生成的文件为 目标文件(Object File),目标文件经过链接将产生 可执行文件(Executable)

目标文件从结构上讲,是已编译过的可执行文件,但还没有经过链接,其中可能有些符号或地址还没有被进行调整(若引用了其他目标文件中的符号)。其本身格式就是可执行文件,只是跟真正的可执行文件在结构上稍有不同。


1. ELF 文件系统

 可执行文件格式主要是 Windows 下的 PE 与 Linux 下的 ELF,它们都是 COFF 格式的变种。

 目标文件就是源代码编译后但未经链接的中间文件(Windows 下的.obj与 Linux 下的.o),它与可执行文件的内容与结构相似,一般采取可执行文件的格式存储。

 同时,动态链接库(DLL)(Windows 的.dll与 Linux 的.so)及 静态链接库(Windows 的.lib与 Linux 的.a)文件都按照可执行文件格式存储。但静态链接库稍有不同,它是将很多目标文件捆绑在一起形成一个文件,再加上一些索引,可以简单地将其理解为一个目标文件包。

上述的目标文件、动态链接库、静态链接库,虽文件后缀不同,但在各平台中都是以可执行文件的格式存储的(Windows 下的 PE 与 Linux 下的 ELF)。


2. 目标文件主要内容

 目标文件中内容会包括 机器指令代码数据,还有链接时所需的信息,如符号表调试信息字符串 等。

 目标文件会将这些信息作为不同的属性,以 节(Section) 的形式存储,也称 段(Segment)

 ELF 文件的开头是一个 ELF 文件头(ELF header),它描述了整个文件的文件属性,同时包含一个描述文件中各个段的 段表(Section Table)。段表是一个数组,描述了各个段在文件中的偏移位置以及段的属性等,从段表中可以获取各个段的所有信息。

 程序的源代码被编译后主要分成两种段:程序指令与程序数据(仅指全局变量与静态变量)。程序指令存储在代码段,而程序数据存储在数据段与.bss段。:

  1. 机器指令被放在 代码段 里;

  2. 而源码编译的数据分为两种:

    • 已初始化的全局变量与静态变量存放在 数据段(只读数据段/.rodata);
    • 未初始化的全局变量与静态变量存放在 .bss 段中。

在目标文件中,将编译后的程序代码与程序数据分开存储的原因有:

  • 安全:能够使得程序被装载后,数据和指令被映射到两个虚存区域。由于数据区域对于进程是可读可写的,而指令区域要求只读,故这样可以分别设置两个区域的读写权限,防止程序指令被篡改。

  • 性能:指令区和数据区的分离有利于提高程序的局部性,能够提高 CPU 的缓存命中率。

  • 节省内存:运行在系统中的多个程序副本,能够使用同一份指令,从而节省内存(特别是在动态链接的系统中)。


3. 目标文件分析

 以以下SimpleSection.c代码为例,介绍如何分析一个目标文件:

/*
* SimpleSection.c
*
* Linux:
*   gcc -c SimpleSection.c
*/

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;
}


① 查看目标文件

 使用binutils的工具objdump能够查看目标文件的内部结构:

  1. 打印各个段的基本信息(-h):
    objdump -h SimpleSection.o
    

  2. 打印各个段的详细信息(-x):
    objdump -x SimpleSection.o
    

  3. 以十六进制的方式打印所有段的内容(-s):
    objdump -s SimpleSection.o
    

 同时,还可以使用命令size,查看各段的长度(dec 表示各段长度之和的十进制结果):

size SimpleSetcion.o


② 代码段

 源程序编译后产生的机器代码保存在代码段,代码段的名称为.text.code

objdump-d选项,可以将输出结果中包含指令的段反汇编,结合上述-s选项使用可以打印出代码的反汇编结果(汇编代码):

objdump -s -d SimpleSection.o


② 数据段和只读数据段

 目标文件中又两种保存数据的段:

  1. 数据段.data:保存已经初始化的全局变量与静态变量

  2. 只读数据段.rodata:保存只读数据,一般是程序中的只读变量(const)与字符串常量。

将只读数据分开存储,能够让程序在装载的时候将.rodata段的属性映射为只读,保证程序的安全性。

有时候,编译器也会将字符串常量放在.data段。


③ BSS段

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

 实际上,.bss只是为这些变量预留空间,等到最终链接成可执行文件的时候再在.bss段中分配空间。故.bss段没有内容,在文件中不占据空间。(而有些编译器可能会直接存放)。

未初始化的全局变量与静态数据默认值为 0,将它们存放到段.data并分配空间存放数据 0 会造成空间浪费。


初始化为默认值的变量也将保存到 .bss 段

 对于:

static int x1 = 0;
static int x2 = 1;

x1将被存放在.bss中,而x2将被存放在.data。因为x1初始值为0,故可以认为是未初始化的,编译器会进行优化,将其放在.bss段,以节省磁盘空间。


④ 其他段

 以上段都为系统保留段,段名都是以.作为前缀。

 此外,应用程序也可以自定义段,前提是段名不能以.作前缀,以防止与系统保留字的冲突。

可以使用objdump工具将一个二进制文件写入目标文件,作为目标文件中的一个段。


4. 目标文件结构

 ELF 文件的最前部是 ELF 文件头,其包含了描述整个文件的基本属性,如文件版本号、目标机器型号、程序入口地址等。

 ELF 头中与 ELF 文件有关的重要结构是 段表,描述了 ELF 文件中所有段的信息,如段名、段长度、在文件中的偏移等。

① 文件头

 使用readelf命令可以查看 ELF 文件详细信息。如查看 ELF 文件头:

readelf -h SimpleSection.o

 ELF 文件头中定义了 ELF 魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF 重定位类型、入口地址、段表文件和长度、段的数量等。

 用以表示 ELF 文件头的数据结构及常量定义在/usr/include/elf.h。由于 ELF 文件有 32 位与 64 位版本,故文件头结构也有两种版本,分别为Elf32_EhdrElf64_Ehdr。32 位版本与 64 位版本的 ELF 文件头结构内容相同(具有相同的成员),但由于数据长度不一样,为了保证兼容,elf.h定义了一套变量体系:

// 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_shastrndx;
}

 成员含义:


魔数

 魔数用以识别 ELF 文件,魔数用一串字节位来标识文件的类型、机器类型、ELF 文件端序等。

文件类型

e_type成员表示 ELF 文件类型,即目标文件、动态链接库、静态链接库中的一种,每种文件类型对应一个常量。系统通过这个常量来识别 ELF 的真正文件类型,而非通过拓展名。

 相关常量以ET_开头:


机器类型

 ELF 文件格式在多个平台通用,但这不代表同一个 ELF 文件可以在不同的平台下使用,而是标识多个平台下的 ELF 文件都遵循一套 ELF 标准。


② 段表

段表保存 ELF 文件中各个段的基本属性,ELF 文件的段结构就是由段表决定,编译器、链接器和装载器依靠段表来定位与访问各个段的属性。

 段表在 ELF 文件中的位置由 ELF 文件头的e_shoff成员决定。

objdump -h只能查看关键的段,而使用readelf则能显示真正的段表结构:

readelf -S SimpleSection.o

 段表实际上是一个Elf32_Shdr数组(32位平台下),数组的第一个元素是NULL,除此之外的每个Elf32_Shdr对应一个有效段。

Elf32_Shdr称为段描述符,定义在/usr/inculde/elf.h

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


③ 重定位表

 目标文件中会有名为.rel *的段,它们为重定位表。

 链接器在处理目标文件时,须对目标文件中代码段与数据段对绝对地址的引用进行重定位。这些重定位信息都保存在文件的重定位表里,每个需要重定位的代码段或数据段都会有一个相对应的重定位表


④ 字符串表

 对于 ELF 文件中使用到的字符串,如段名、变量名等,ELF 文件将它们集中保存到一个表,然后使用字符串在表中的偏移来引用字符串。

 通过这种方式,在 ELF 文件中引用字符串只需给出一个数字下标,无需考虑字符串长度的问题(字符串长度是不确定的,在文件使用固定的结构表示比较困难)。

 字符串表在文件中有两种形式:

  1. .strtab字符串表:保存普通的字符串,比如符号名;

  2. .shstrtab段表字符串表:保存段表中用到的字符串,比如段名。


5. 符号

 链接中,我们将函数与变量统称为 符号(Symbol),函数名或变量名就是 符号名

 每一个目标文件都有一个 符号表,记录了目标文件中用到的所有符号。每个符号都有一个符号值,对于变量与函数而言,符号值就是地址。

 符号表中有以下几种类型的符号:

  • 定义在本目标文件的全局符号,可以被其他目标文件引用;

  • 未定义在本目标文件,却在本目标文件中引用的全局符号,称 外部符号

  • 段名,由编译器产生,其符号值为该段的起始地址;

  • 局部符号,这类符号只在编译单元内可见;

  • 行号信息,目标文件指令与源代码中代码行的对应关系


① 符号表结构

 ELF 符号表也是目标文件中的一个段,段名一般为.symtab。符号表的结构是一个Elf32_Sym数组,每个Elf32_Sym结构对应一个符号。符号数组的第一个元素同样为无效的“未定义”符号。

// Elf32_Sym

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


② 特殊符号

 当使用ld作为链接器产生可执行文件时,它会为我们定义很多特殊符号。这些符号并没有在程序中定义,但你可以直接声明并引用它们,链接器会在链接的时候将它们解析为正确的值,这种符号称为特殊符号。

 几个有代表性的特殊符号如下:

  • __executable_start:程序的起始地址(不是入口地址,而是程序的最开始的地址);

  • __etext_etextetext:代码段结束地址,即代码段最末尾的地址;

  • _edataedata:该符号为数据段结束地址,即数据段最末尾地址;

  • _endend:程序结束地址

 以上都为程序被装载时的虚拟地址。


③ 符号修饰与函数签名

 在以前,UNIX 下为了防止多种语言目标文件之间的符号冲突,C 源码中定义的符号在经过编译后,相对应的符号名前会加上_。而现在的 Linux 下 GCC 编译器中,已默认去掉了这种方式。

 同时 C++ 增加了名称空间的概念,来解决多模块间的符号冲突(而 C 语言对于目标文件中完全相同的符号,还是会产生符号冲突)。


C++ 符号修饰

 C++ 拥有复杂的符号管理需求,而为了解决此类需求,人们引入了 符号修饰 机制。

 编译器将 C++ 源码编译成目标文件时,会将函数与变量名进行修饰,形成符号名。C++ 编译器与链接器都使用这个符号来识别或处理函数与变量。所以对于不同函数签名的函数而言,即使函数名相同,编译器与链接器都认为它们是不同的函数。

不同的编译器厂商使用的名称修饰方法可能不同,故会导致由不同编译器编译产生的目标文件无法互相链接,这是导致不同编译器之间不能互相操作的主要原因之一。


④ extern "C"

 C++ 为了与 C 兼容,在符号的管理上,引入了用来声明或定义 C 符号的extern "C"用法:

extern "C" {
    int func(int);
    int var;
}

 编译器会将大括号内的代码当做 C 语言代码处理,故这些代码中,C++ 的名称修饰机制不会起作用。

extern "C" int func(int);
extern "C" int var;

 这种用法使得 C++ 与 C 两种语言之间,能够相互引用彼此头文件中定义的符号。


⑤ 弱符号与强符号

 对于定义的符号,有 强符号弱符号 之分。函数与初始化了的全局变量为强符号,未初始化的全局变量为弱符号

 我们也可以通过 GCC 的__attribute__((weak))来将一个强符号定义为若符号。

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

  • 不允许强符号被多次定义(即多个目标文件中不能有同名的强符号);

  • 若一个符号仅在一个文件中为强符号,在其他文件中为弱符号,则选取强符号作为定义;

  • 若一个符号在所有目标文件中都是弱符号,则选择其中占用空间最大的一个作为定义


强引用与弱引用

 在目标文件链接成最终的可执行文件时,文件中的 外部引用 都需要依据强符号与弱符号的定义进行符号决议。对于此过程,链接器有强引用与弱引用的区分:

  • 强引用:若未找到该符号的定义,链接器会报符号未定义错误;

  • 弱引用:若未找到该符号的定义,链接器对该引用不报错,而是将其赋为默认值。弱符号与 COMMON 块的联系紧密

 GCC 中,我们可以通过__attribute__((weakref))关键字,来讲一个外部函数的引用声明为 弱引用,如:

__attribute__((weakref)) void foo();

int main()
{
    foo();
}

 强引用与弱引用的辨析对于库来说十分有用。


6. 调试信息

 目标文件内还可能保存调试信息,前提是编译器必须提前将源码与目标代码间的关系进行保存。

 使用gcc编译器时,加上参数-g,编译器就会在产生的目标文件中加上调试信息。通过readelf可以看到,目标文件中加了很多debug相关的段。

 调试信息在目标文件与可执行文件中占用很大空间,故开发完程序要发布时,可以使用strip命令去掉调试信息。

$strip foo



三、静态链接

 我们可以通过ld链接器将a.ob.o两个目标文件进行链接:

$ld a.o b.o -e main -o ab
  • -e:指定程序入口函数,ld链接器的默认程序入口为_start

  • -o:指定输出文件名,默认为a.o


1. 空间与地址分配

 对于链接器来说,链接过程就是将多个输入的目标文件加工后合成为一个 输出文件。而对于多个输入的目标文件,链接器一般有两种方法将各个段进行合并:

① 按序叠加

 最简单的方法就是将输入文件的所有段按次序叠加。

 由于每个段都有一定的地址和空间对齐要求,故这种方法会导致输出的文件中有很多零散的段。


② 相似段合并

 一个更实际的方法是将相同性质的段合并到一起,如将所有输入文件的.text段合并到输出文件的.text段。

 此外,.bss在目标文件与可执行文件中不占文件的空间,但在装载时占用地址空间。链接器在合并各个段的同时,也会将.bss段合并,同时分配虚拟空间

 对于有实际数据的段,如.text.data,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在;而对于.bss这样的段来说,分配空间的意义仅限于虚拟地址空间的分配。


实际的地址与空间分配

 对于链接时的空间分配,有两个含义:

  1. 输出的可执行文件中的空间

  2. 装载后的虚拟地址中的虚拟地址空间(虚拟地址为程序访问存储器时使用的逻辑地址

 而对于链接时的空间分配,我们一般只关注于虚拟地址空间的分配,因为这关系到链接器后面的关于地址的计算,而可执行文件本身的空间分配与链接过程关系不大。

 现在的链接器空间分配都采用 相似段合并 的方法,使用这种方法的链接器一般都采用 两步链接 的方法:

  1. 空间与地址分配:扫描所有输入目标文件,获取它们的各个段长度、属性与位置,并将输入目标文件中的符号表中所有的 符号定义符号引用 收集起来,统一放到全局符号表。(经过这一步操作,链接器能够获取所有输入目标文件的段长度,并将它们合并,计算出各个段合并后的长度与位置)

  2. 符号解析与重定位:使用上面第一步中收集到的信息,读取输入文件中段的数据、重定位信息,进行符号解析与重定位、调整代码中的地址等

 在第一步中,输入文件中各个段在链接后的虚拟地址已确定。然后,链接器开始计算各个符号的虚拟地址,因为各个符号在段内的相对地址是确定的,故只需加上偏移量,各符号的虚拟地址就能确定(无需重定位的符号)。

 以a.ob.o链接的结果为例:
 可见,在连接之前,目标文件中所有段的 VMA(虚拟地址)都是0,因为虚拟可见还未被分配;连接之后,可执行文件中各个段都被分配到了相应的虚拟地址。

Linux 下,ELF 可执行文件默认从地址0x080480000开始分配。


2. 符号解析与重定位

 完成空间和地址的分配步骤后,链接器就进入符号解析与重定位步骤,即两步链接的第二步。

① 重定位

 重定位是针对代码中使用到的外部符号而言的,重定位时,链接器会修改目标机器代码中对应指令操作的地址,即修改 重定位入口。(重定位入口:需要被重定位的地方)

 若源码中用到了外部符号,编译器编译的时候并不知道这些符号的地址,编译器会暂时将这些符号的地址设为“0”。

 而在链接过程中,当链接器完成地址与空间分配后,就能够确定所有符号的虚拟地址,链接器就会根据符号的地址对每个需要重定位的指令进行地址修正。


② 重定位表

 在 ELF 文件中,有一个 重定位表 结构,专门保存与重定位相关的信息,用以描述如何修改相应段内的内容。对于 可重定位 的 ELF 文件来说,它必须包含重定位表。

 对于每个要被重定位的 ELF 段都有一个对应的重定位表,而一个重定位表就是 ELF 文件中的一个段。如代码段.text若有要被重定位的地方,则会有一个.rel text段,保存了代码段的重定位表。


③ 符号解析

 可以说,符号的解析占据了链接过程的主要内容。

 在重定位的过程中,每个重定位的入口都是对一个符号的引用,当链接器必须对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会查找由所有输入目标文件的符号表组成的全局符号表,以获取符号的地址。


3. COMMON 块

 COMMON 块用于解决当一个符号在多个目标文件中都为弱符号,且它们的类型各不相同时的符号决议。

 在编译时,编译器会将弱符号的类型设为 COMMON 类型;链接器链接时会选择占用空间最大的弱符号作为最终定义,而非通过符号的类型(即通过占用空间而非类型进行决议)。

这样做的原因在于,链接器无法直接识别符号类型。


4. C++ 相关问题

 C++ 的一些特性必须由编译器与链接器提供支持才能够实现,如:


① 重复代码消除

 C++ 编译器有时候会产生重复的代码,如模板、外部内联函数与虚函数表,都有可能在不同的编译单元内产生相同的代码。

 比较有效的解决方法是,将每个模板的示例代码单独存放在一个段里,每个段只包含一个模板实例。

 如有个模板函数是add<T>(),某编译单元以intfloat类型实例化了该模板函数,那么该编译单元的目标文件中就包含了两个该模板实例的段。当别的编译单元也以intfloat类型实例该模板函数时,也会生成相同的函数名,当链接器链接的时候就可以区分相同的模板实例段,然后将它们合并入最后的代码段。

 这种方法可能带来的问题是,相同名称的段可能拥有不同内容(不同编译单元使用了不同的编译器版本或进行了编译优化),在这种情况下,链接器可能会随意选择一个副本作为链接的输入,并提供告警信息。


函数级别链接

 对于庞大的程序或库而言,若将其中的所有的符号都输出至可执行文件,可能会导致可执行文件臃肿。故有的编译器提供函数级别的链接,即编译的时候将所有函数单独保存到一个段里,链接的时候只会选择使用到的函数进行合并,防止输出文件过于臃肿。

 这种方法的缺点在于随着段数目的增加,目标文件的数量也会变得比较大;重定位的过程也更加复杂。


② 全局构造与析构

 一般 C/C++ 程序都是从main函数开始执行,随着main的结束而结束。而实际上,程序开始前需要初始化进程执行环境,C++ 的全局构造/析构函数就是在此时期执行的。

 Linux 系统下程序的入口是_start,这个函数是 Linux 系统库(Glibc)的一部分,当我们的程序与 Glibc 库链接形成最终可执行的文件以后,这个函数就是程序初始化部分的入口,程序初始化部分完成一系列初始化过程(如构造函数)后,会调用main函数来执行程序的主体。main函数执行完成后,返回初始化部分,进行一些清理工作(如析构函数),然后再结束进程。

 ELF 文件中还定义了两种特殊的段:

  • .init:保存可执行指令,它构成了进程的初始化代码。当一个程序开始运行时,在调用main函数之前,Glibc 的初始化部分安排执行这个段中的代码

  • .fini:保存着进程终止代码指令。当一个main函数正常退出时,Glibc 会安排执行这个段中的代码

 若一个函数被放到.init段,则在main函数调用前会执行这个函数(.fini同理)。利用这个特性,能够实现 C++ 全局构造/析构函数。


③ C++ 与 ABI

 若要使两个编译器编译出的目标文件能够相互连接,则这两个目标文件必须满足:

  • 相同的目标文件格式

  • 相同的符号修饰标准

  • 变量的内存分布方式相同(大小端)

  • 函数的调用方式相同

 其中我们把这些特性与可执行二进制代码的兼容称为 ABI(Application Binary Interface)

与 API 的区别:
 API 指的是源代码级别的接口;而 ABI 指的是二进制层面的接口,ABI 的兼容程序比 API 要严格。

 C++ 的缺点之一就是其二进制兼容性相比 C 语言要查。不仅不用编译器编译的二进制代码之间无法互相兼容,有时连同一个编译器的不同版本之间的兼容性也不好。


5. 静态库链接

 一般情况下,一种语言的开发环境往往附带有 语言库(Language Library)(如常用的 C 语言静态库 libc)。

 一个静态库可以看成 一组目标文件的集合,即很多目标文件打包后形成的一个文件。

 以glibc为例,glibc是以 C 语言编写的,由很多对应功能代码组成的库,它由成百上千个源代码文件组成,编译后也将产生相同数量的目标文件。若将这些文件直接提供给库的使用者,将会造成不便,于是人们通常用ar压缩程序将这些目标文件压缩到一起,并对它们进行索引与编号,形成静态库文件libc.a

 且为了防止最终的输出文件过大,库中的每个函数都尽可能独立地放在一个目标文件中,这样对于没有用到的目标文件(函数)就不会链接到输出文件中,避免空间浪费。

*.a为压缩后的库文件。

 在使用时,ld链接器会自动寻找所有需要的符号与目标文件,将这些目标文件从库中解压,并连接形成最终的可执行文件。


6. 链接过程控制

 控制链接过程的方法有:

  • 使用命令行参数来给链接器指定参数;

  • 将链接指令存放在目标文件里(编译器通常通过这种方法向链接器传递指令);

  • 使用链接控制脚本

 一般情况下,我们使用链接器提供的默认链接规则对目标文件进行链接即可。

 但在某些特殊程序,如:操作系统内核、BIOS或一些在没有操作系统的情况下运行的程序,这些程序往往对段的操作有特殊需求,这时候就需要用到链接控制脚本的方法。



[第三部分] 装载与动态链接

一、可执行文件的装载与进程

 可执行文件只有被读取到内存之后才能被 CPU 执行,这个过程叫做 装载

 早期的程序装载十分简单,只需把程序从外部存储中读取到内存的某个位置即可。而随着 MMU 的诞生,多进程、多用户、虚拟存储的操作系统出现后,可执行文件的装载过程变得非常复杂。


1. 进程虚拟地址空间

 每个进程都拥有独立的 虚拟地址空间,虚拟地址空间的大小由 CPU 的位数决定。

 如 32 位的硬件平台决定了虚拟地址空间的地址为 0 ~ 2^{32}-1,即 0x00000000 ~ 0xFFFFFFFF,约 4GB。

 而从程序的角度来看,C 语言指针大小的位数与虚拟空间的位数相同。如 32 位平台下的指针为 32 位,64 位平台下的指针为 64 位。

 进程使用虚拟内存空间时需要由操作系统管理分配,若进程访问未经允许的空间,将被视为非法操作,从而导致进程的强制结束。


2. 装载方式

 要将程序执行时所需指令与数据读入内存,一般有:

  • 静态装载:最简单的方式,直接将所有指令与数据读入内存中;缺点是占用的内存较大。

  • 动态装载:利用程序的 局部性原理,当程序用到哪个时,才将哪个模块装入内容;这是实际的装载方式。

 动态装载的方法也有两种:

① 覆盖装入

 虚拟存储发明之前的主要方法就是覆盖载入,现在已几乎淘汰。

 覆盖装入将支配内存的潜力交给程序员,程序员编写程序时必须手工将程序分割成若干块,然后编写覆盖管理器(一段辅助代码)来管理这些模块的驻留、替换。

 在多个模块的情况下,程序员需要手工将模块按照调用依赖关系组织成树状结构。且需要保证两点:

  • 树状结构中任一模块到根模块(main函数)的路径都叫做 调用路径,当模块被调用时,需保证整个调用路径上的模块都在内存中;

  • 禁止跨树调用,任意模块不许跨过树状结构进行调用。

 覆盖装入的缺点就是速度较慢。


② 页映射

 页映射是虚拟存储机制的一部分,它将装载的细粒度将为页(相较于的覆盖装入的页)。而此时装载管理器,就是操作系统的存储管理器。

 页映射将内存与磁盘中的数据与指令以页为单位,进行装载与操作。

 在释放页时,可以选择 FIFO 算法,也可以选择 LRU 算法(最少使用算法)。


3. 可执行文件的装载

 当创建一个进程,然后装载相应的可执行文件并执行时,需要经过三个步骤:

  1. 创建独立的虚拟地址空间

  2. 读取可执行文件头,建立虚拟空间与可执行文件的映射

  3. 将 CPU 的指令寄存器设置成可执行文件的入口地址,启动运行

① 创建虚拟地址空间

 创建虚拟空间实际上并不是进行空间的创建,而是创建表示映射所需的数据结构,以实现虚拟空间各个页到相应物理空间的映射。


② 建立映射关系

 第一步中建立了虚拟空间到物理内存的映射关系,而这一步需要做的是虚拟空间到可执行文件之间的映射,即确定程序当前需要的页在可执行文件中的哪个位置。

可执行文件很多时候被叫做 映像文件


③ 修改指令寄存器

 最后,操纵系统通过设置 CPU 的指令今存其将控制权转交给进程,跳转到 ELF 文件头中保存的入口地址。


4. 进程虚存空间分布

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

 操作系统装载可执行文件时,并不关心文件各个段的实际内容,而只关心装载有关的问题,主要提现为段的权限。而段的权限基本只有三种:

  • 可读可执行:代码段为代表

  • 可读可写:数据段与 BSS 段为代表

  • 只读:只读数据段为代表

 对于相同权限的段,操作系统将它们合并到一起作为一个段进行映射(而非以 ELF 文件中的段为单位)。这样能够有效减少页面内部碎片,节省内存空间。

 同时,在将目标文件链接成可执行文件时,链接器也会尽量将相同权限的段分配在同一空间。


Last Modified : 2020-09-26 21:41:57