《程序员的自我修养:链接、装载与库》
发布于 2020-08-05 02:09:54 阅读量 229 点赞 0
[第一部分] 简介
1. 简介
北桥南桥
计算机中,三个最重要的部件为:中央处理器 CPU、内存、I/O 控制芯片。
于此同时,由于北桥芯片的速度非常高,所有相对低速的设备若直接连接到北桥,北桥同时处理高速与低速设备,将导致设计十分复杂,于是又有了南桥芯片。南桥芯片用于专门处理低速设备,将它们汇总后连接到北桥上。如磁盘、USB、键盘、鼠标等都是连接在南桥上。
应用程序编程接口
应用程序编程接口(Application Programming Interface)即俗称的 API,由运行库提供,不同的运行库提供不同的 API。
操作系统内裤对于硬件层来说是硬件接口的使用者,而硬件是硬件接口的定义者,硬件的接口定义决定了操作系统该使用的内核。
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)与二元信号量类似,资源仅同时允许一个线程获取资源;但与信号量不同的是,信号量在整个系统中可以被任意线程释放。
临界区
临界区是比互斥量更加严格的同步手段。
读写锁
读写锁致力于更加特定的场合。
读写锁状态 | 以共享方式获取 | 以独占方式获取 |
---|---|---|
自由 | 成功 | 成功 |
共享 | 成功 | 等待 |
独占 | 等待 | 等待 |
可重入
重入指的是一个函数调用没有执行完成,而由于外部因素或内部调用,又一次进入该函数执行。一个函数要被重入,只有两种情况:
多个线程同时执行这个函数
函数自身调用自身
一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后果。一个函数是可重入的,需要满足以下条件:
不用任何(局部)静态或全局非
const
变量不返回任何(局部)静态或全局非
const
变量的指针仅依赖于调用方提供的参数
不依赖任何单个资源的锁(
mutex
等)不调用任何不可重入的函数
可重入是线程安全的保障,一个可重入的函数可以在多线程的环境下放心使用。
过度优化
即使合理地使用了锁,也不一定能保证线程安全,这是源于落后的编译器技术已经无法满足日益增长的并发需求。比如,编译器在进行优化的时候,可能会为了效率交换毫不相干的两条相邻指令的执行顺序。
volatile
关键字阻止过度优化,volatile
可以确保两件事:
阻止编译器为提高速度将一个变量缓存到寄存器内而不回写(即将变量缓存在线程本地的工作内存中,详见[线程资源共享]);
阻止编译器调整操作
volatitle
变量的指令顺序
三种多线程模型
大多数操作系统都在内核里提供线程支持,内核线程同样由多处理器或调度来实现并发。而用户实际使用的用户态线程不一定在操作系统内核里对应相等数量的内核线程。下面介绍用户态多线程库的三种实现模型:
1. 一对一模型
对于一对一模型来说,一个用户使用的线程唯一对应一个内核使用的线程(反过来则不一定,一个内核里的线程不一定有对应的用户态线程存在)。

但与此同时,一对一线程有两个缺点:
操作系统会限制内核线程的数量,故一对一线程会让用户的线程数量受到限制
内核线程调度时,上下文切换的开销较大,导致用户线程执行效率低下
2. 多对一模型
多对一模型将多个用户线程映射到一个内核线程上,线程间的切换由用户态的代码来进行,因此多对一模型的线程切换相较于一对一的速度更快。

但同时,多对一模型的好处在于高效的上下文切换与近乎无限制的线程数量。
3. 多对多模型
多对多模型结合了一对一模型和多对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上。

[第二部分] 静态链接
一、编译和链接
一般的 IDE,会将编译与连接操作合并到一起,称为构建。
1. 编译程序
在 Linux 环境下,只需使用 GCC 就可编译程序:
$gcc hello.c # 编译程序
$./a.out # 运行输出程序
而事实上,上述的过程可以分为 4 个步骤:预处理、编译、汇编和链接。
编译操作特指一个程序的编译,包括预处理、编译、汇编、链接的组合过程。
而编译则指编译操作中单独的编译环节;同时,编译也可指预编译、编译与汇编的组合过程,即通过编译,产生未链接的目标文件。
① 预编译(输出.i)
预编译(可以理解为预处理)是指,使用预编译器将源代码文件和相关的头文件,预编译成一个文件。预编译主要处理在源代码文件中以#
开头的预编译指令,输出一个与源代码文件等效的代码文件。
C 程序的源代码文件拓展名为
.c
,头文件是.h
,预编译后的文件拓展名是.i
;C++ 程序的源代码文件拓展名为
.cpp
或.cxx
,头文件可能是.hpp
,而预编译后的文件拓展名是.ii
。
进行预编译可以通过:
gcc
编译器与选项-E
:$gcc -E hello.c -o hello.i
cpp
预编译器:$cpp hello.c > hello.i
预编译的工作如下:
- 删除
#define
语句,并展开所有的宏定义; - 处理条件预编译指令如:
#if
、#ifdef
、#elif
、#else
、#endif
; - 处理
#include
指令,将被包含的文件插入到该预编译指令的位置,这个过程是递归进行的(被包含的文件可能包含其他文件); - 删除所有注释;
- 添加行号和文件名标识,以便编译时产生调试的行号信息及用于编译错误时产生包含行号的警告;
- 保留所有
#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
)可以通过:
gcc
编译器与选项-c
:$gcc -c hello.s -o hello.o
as
汇编器:$as hello.s -o hello.o
也可以使用
gcc
编译器,直接由源代码文件输出目标文件(不进行链接):
$gcc -c hello.c -o hello.o
④ 链接
链接是指将多个目标文件进行组合,并输出一个可执行文件的过程。
2. 编译器的工作
从直观的角度来看,编译器就是将高级语言翻译成机器语言的工具。编译器的工作过程一般可以分为六步:扫描、语法分析、语义分析、源代码优化、代码生成与目标代码优化。

array[index] = (index+4) * (2+6);
① 词法分析
首先将源代码程序输入到 扫描器(Scanner) 进行词法分析,扫描器运行有限状态机将源代码的字符序列分割成一系列记号(Tokens)。

在识别记号的同时,扫描器还会进行其他工作:将标识符放到符号表、将字面量放到文字表等。以备后续的步骤使用。
程序
lex
用实现词法扫描,它会按照用户之前描述好的词法规则将输入的字符串分割成一个个记号。因为这个程的序存在,编译器开发者只需改变词法规则,而无需为每个编译器开发独立的词法扫描器。
将源代码字符序列分割成记号(关键字、标识符、字面量、特殊符号)序列。
② 语法分析
语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生以表达式为节点的 语法树(Syntax Tree)。这个过程采用了上下文无关语法的分析手段。

*
)。若出现了表达式不合法,如括号不匹配、操作符确实等,编译器就会报告语法分析阶段的错误。正如词法分析器一样,语法分析器也有现成的工具
yacc
,它与lex
类似,可以根据用户给定的语法规则对输入的记号序列进行解析,从而构建一棵语法树。对于不同的编程语言,编译器的开发者只需改变语法规则,而无需编写一个新的语法分析器。
确定各种符号的优先级与含义,基于输入的记号序列生成语法树。
③ 语义分析
接下来 语义分析器(Semantic Analyzer) 进行语义分析。
静态语义通常包括声明和类型的匹配,类型的转换。经过语义分析阶段之后,语法树的表达式都被标识了类型,若有些类型需要做隐式类型转换,语义分析程序会在语法树中插入相应的转换节点。

标记语法树中表达式的类型,并进行隐式类型转换。
④ 源代码优化
现代编译器往往会在源代码级别进行优化,源代码优化器(Source Code Optimizer) 用来进行在源代码层面的优化。

x = y operator z
表示将y
与z
经过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 格式的变种。
.obj
与 Linux 下的.o
),它与可执行文件的内容与结构相似,一般采取可执行文件的格式存储。同时,动态链接库(DLL)(Windows 的
.dll
与 Linux 的.so
)及 静态链接库(Windows 的.lib
与 Linux 的.a
)文件都按照可执行文件格式存储。但静态链接库稍有不同,它是将很多目标文件捆绑在一起形成一个文件,再加上一些索引,可以简单地将其理解为一个目标文件包。
上述的目标文件、动态链接库、静态链接库,虽文件后缀不同,但在各平台中都是以可执行文件的格式存储的(Windows 下的 PE 与 Linux 下的 ELF)。
2. 目标文件主要内容
目标文件中内容会包括 机器指令代码、数据,还有链接时所需的信息,如符号表、调试信息、字符串 等。
目标文件会将这些信息作为不同的属性,以 节(Section) 的形式存储,也称 段(Segment)。
程序的源代码被编译后主要分成两种段:程序指令与程序数据(仅指全局变量与静态变量)。程序指令存储在代码段,而程序数据存储在数据段与
.bss
段。:
机器指令被放在 代码段 里;
而源码编译的数据分为两种:
- 已初始化的全局变量与静态变量存放在 数据段(只读数据段/
.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
能够查看目标文件的内部结构:
- 打印各个段的基本信息(
-h
):objdump -h SimpleSection.o
- 打印各个段的详细信息(
-x
):objdump -x SimpleSection.o
- 以十六进制的方式打印所有段的内容(
-s
):objdump -s SimpleSection.o
同时,还可以使用命令
size
,查看各段的长度(dec 表示各段长度之和的十进制结果):
size SimpleSetcion.o
② 代码段
源程序编译后产生的机器代码保存在代码段,代码段的名称为.text
或.code
。
objdump
的-d
选项,可以将输出结果中包含指令的段反汇编,结合上述-s
选项使用可以打印出代码的反汇编结果(汇编代码):
objdump -s -d SimpleSection.o

② 数据段和只读数据段
目标文件中又两种保存数据的段:
数据段
.data
:保存已经初始化的全局变量与静态变量只读数据段
.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 文件中所有段的信息,如段名、段长度、在文件中的偏移等。
① 文件头
使用readelf
命令可以查看 ELF 文件详细信息。如查看 ELF 文件头:
readelf -h SimpleSection.o
ELF 文件头中定义了 ELF 魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF 重定位类型、入口地址、段表文件和长度、段的数量等。
/usr/include/elf.h
。由于 ELF 文件有 32 位与 64 位版本,故文件头结构也有两种版本,分别为Elf32_Ehdr
、Elf64_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 文件的段结构就是由段表决定,编译器、链接器和装载器依靠段表来定位与访问各个段的属性。
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 文件将它们集中保存到一个表,然后使用字符串在表中的偏移来引用字符串。


字符串表在文件中有两种形式:
.strtab
字符串表:保存普通的字符串,比如符号名;.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
或_etext
或etext
:代码段结束地址,即代码段最末尾的地址;_edata
或edata
:该符号为数据段结束地址,即数据段最末尾地址;_end
或end
:程序结束地址
以上都为程序被装载时的虚拟地址。
③ 符号修饰与函数签名
在以前,UNIX 下为了防止多种语言目标文件之间的符号冲突,C 源码中定义的符号在经过编译后,相对应的符号名前会加上_
。而现在的 Linux 下 GCC 编译器中,已默认去掉了这种方式。
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 两种语言之间,能够相互引用彼此头文件中定义的符号。
⑤ 弱符号与强符号
对于定义的符号,有 强符号 与 弱符号 之分。函数与初始化了的全局变量为强符号,未初始化的全局变量为弱符号。
__attribute__((weak))
来将一个强符号定义为若符号。针对强符号与弱符号的概念,链接器会按照如下规则处理被多次定义的全局符号:
不允许强符号被多次定义(即多个目标文件中不能有同名的强符号);
若一个符号仅在一个文件中为强符号,在其他文件中为弱符号,则选取强符号作为定义;
若一个符号在所有目标文件中都是弱符号,则选择其中占用空间最大的一个作为定义
强引用与弱引用
在目标文件链接成最终的可执行文件时,文件中的 外部引用 都需要依据强符号与弱符号的定义进行符号决议。对于此过程,链接器有强引用与弱引用的区分:
强引用:若未找到该符号的定义,链接器会报符号未定义错误;
弱引用:若未找到该符号的定义,链接器对该引用不报错,而是将其赋为默认值。弱符号与 COMMON 块的联系紧密
GCC 中,我们可以通过__attribute__((weakref))
关键字,来讲一个外部函数的引用声明为
弱引用,如:
__attribute__((weakref)) void foo();
int main()
{
foo();
}
强引用与弱引用的辨析对于库来说十分有用。
6. 调试信息
目标文件内还可能保存调试信息,前提是编译器必须提前将源码与目标代码间的关系进行保存。
gcc
编译器时,加上参数-g
,编译器就会在产生的目标文件中加上调试信息。通过readelf
可以看到,目标文件中加了很多debug
相关的段。调试信息在目标文件与可执行文件中占用很大空间,故开发完程序要发布时,可以使用
strip
命令去掉调试信息。
$strip foo
三、静态链接
我们可以通过ld
链接器将a.o
与b.o
两个目标文件进行链接:
$ld a.o b.o -e main -o ab
-e
:指定程序入口函数,ld
链接器的默认程序入口为_start
;-o
:指定输出文件名,默认为a.o
1. 空间与地址分配
对于链接器来说,链接过程就是将多个输入的目标文件加工后合成为一个 输出文件。而对于多个输入的目标文件,链接器一般有两种方法将各个段进行合并:
① 按序叠加
最简单的方法就是将输入文件的所有段按次序叠加。

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

.bss
在目标文件与可执行文件中不占文件的空间,但在装载时占用地址空间。链接器在合并各个段的同时,也会将.bss
段合并,同时分配虚拟空间。对于有实际数据的段,如
.text
、.data
,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在;而对于.bss
这样的段来说,分配空间的意义仅限于虚拟地址空间的分配。实际的地址与空间分配
对于链接时的空间分配,有两个含义:
输出的可执行文件中的空间
装载后的虚拟地址中的虚拟地址空间(虚拟地址为程序访问存储器时使用的逻辑地址)
而对于链接时的空间分配,我们一般只关注于虚拟地址空间的分配,因为这关系到链接器后面的关于地址的计算,而可执行文件本身的空间分配与链接过程关系不大。
空间与地址分配:扫描所有输入目标文件,获取它们的各个段长度、属性与位置,并将输入目标文件中的符号表中所有的 符号定义 和 符号引用 收集起来,统一放到全局符号表。(经过这一步操作,链接器能够获取所有输入目标文件的段长度,并将它们合并,计算出各个段合并后的长度与位置)
符号解析与重定位:使用上面第一步中收集到的信息,读取输入文件中段的数据、重定位信息,进行符号解析与重定位、调整代码中的地址等
在第一步中,输入文件中各个段在链接后的虚拟地址已确定。然后,链接器开始计算各个符号的虚拟地址,因为各个符号在段内的相对地址是确定的,故只需加上偏移量,各符号的虚拟地址就能确定(无需重定位的符号)。
以a.o
与b.o
链接的结果为例:
可见,在连接之前,目标文件中所有段的 VMA(虚拟地址)都是0,因为虚拟可见还未被分配;连接之后,可执行文件中各个段都被分配到了相应的虚拟地址。
Linux 下,ELF 可执行文件默认从地址
0x080480000
开始分配。
2. 符号解析与重定位
完成空间和地址的分配步骤后,链接器就进入符号解析与重定位步骤,即两步链接的第二步。
① 重定位
重定位是针对代码中使用到的外部符号而言的,重定位时,链接器会修改目标机器代码中对应指令操作的地址,即修改 重定位入口。(重定位入口:需要被重定位的地方)
而在链接过程中,当链接器完成地址与空间分配后,就能够确定所有符号的虚拟地址,链接器就会根据符号的地址对每个需要重定位的指令进行地址修正。
② 重定位表
在 ELF 文件中,有一个 重定位表 结构,专门保存与重定位相关的信息,用以描述如何修改相应段内的内容。对于 可重定位 的 ELF 文件来说,它必须包含重定位表。
.text
若有要被重定位的地方,则会有一个.rel text
段,保存了代码段的重定位表。
③ 符号解析
可以说,符号的解析占据了链接过程的主要内容。
3. COMMON 块
COMMON 块用于解决当一个符号在多个目标文件中都为弱符号,且它们的类型各不相同时的符号决议。
这样做的原因在于,链接器无法直接识别符号类型。
4. C++ 相关问题
C++ 的一些特性必须由编译器与链接器提供支持才能够实现,如:
① 重复代码消除
C++ 编译器有时候会产生重复的代码,如模板、外部内联函数与虚函数表,都有可能在不同的编译单元内产生相同的代码。
如有个模板函数是
add<T>()
,某编译单元以int
与float
类型实例化了该模板函数,那么该编译单元的目标文件中就包含了两个该模板实例的段。当别的编译单元也以int
、float
类型实例该模板函数时,也会生成相同的函数名,当链接器链接的时候就可以区分相同的模板实例段,然后将它们合并入最后的代码段。
这种方法可能带来的问题是,相同名称的段可能拥有不同内容(不同编译单元使用了不同的编译器版本或进行了编译优化),在这种情况下,链接器可能会随意选择一个副本作为链接的输入,并提供告警信息。
函数级别链接
对于庞大的程序或库而言,若将其中的所有的符号都输出至可执行文件,可能会导致可执行文件臃肿。故有的编译器提供函数级别的链接,即编译的时候将所有函数单独保存到一个段里,链接的时候只会选择使用到的函数进行合并,防止输出文件过于臃肿。
② 全局构造与析构
一般 C/C++ 程序都是从main
函数开始执行,随着main
的结束而结束。而实际上,程序开始前需要初始化进程执行环境,C++ 的全局构造/析构函数就是在此时期执行的。
_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. 链接过程控制
控制链接过程的方法有:
使用命令行参数来给链接器指定参数;
将链接指令存放在目标文件里(编译器通常通过这种方法向链接器传递指令);
使用链接控制脚本
一般情况下,我们使用链接器提供的默认链接规则对目标文件进行链接即可。
[第三部分] 装载与动态链接
一、可执行文件的装载与进程
可执行文件只有被读取到内存之后才能被 CPU 执行,这个过程叫做 装载。
1. 进程虚拟地址空间
每个进程都拥有独立的 虚拟地址空间,虚拟地址空间的大小由 CPU 的位数决定。
而从程序的角度来看,C 语言指针大小的位数与虚拟空间的位数相同。如 32 位平台下的指针为 32 位,64 位平台下的指针为 64 位。
进程使用虚拟内存空间时需要由操作系统管理分配,若进程访问未经允许的空间,将被视为非法操作,从而导致进程的强制结束。
2. 装载方式
要将程序执行时所需指令与数据读入内存,一般有:
静态装载:最简单的方式,直接将所有指令与数据读入内存中;缺点是占用的内存较大。
动态装载:利用程序的 局部性原理,当程序用到哪个时,才将哪个模块装入内容;这是实际的装载方式。
动态装载的方法也有两种:
① 覆盖装入
虚拟存储发明之前的主要方法就是覆盖载入,现在已几乎淘汰。
在多个模块的情况下,程序员需要手工将模块按照调用依赖关系组织成树状结构。且需要保证两点:
树状结构中任一模块到根模块(
main
函数)的路径都叫做 调用路径,当模块被调用时,需保证整个调用路径上的模块都在内存中;禁止跨树调用,任意模块不许跨过树状结构进行调用。

② 页映射
页映射是虚拟存储机制的一部分,它将装载的细粒度将为页(相较于的覆盖装入的页)。而此时装载管理器,就是操作系统的存储管理器。
在释放页时,可以选择 FIFO 算法,也可以选择 LRU 算法(最少使用算法)。
3. 可执行文件的装载
当创建一个进程,然后装载相应的可执行文件并执行时,需要经过三个步骤:
创建独立的虚拟地址空间
读取可执行文件头,建立虚拟空间与可执行文件的映射
将 CPU 的指令寄存器设置成可执行文件的入口地址,启动运行
① 创建虚拟地址空间
创建虚拟空间实际上并不是进行空间的创建,而是创建表示映射所需的数据结构,以实现虚拟空间各个页到相应物理空间的映射。
② 建立映射关系
第一步中建立了虚拟空间到物理内存的映射关系,而这一步需要做的是虚拟空间到可执行文件之间的映射,即确定程序当前需要的页在可执行文件中的哪个位置。
可执行文件很多时候被叫做 映像文件。
③ 修改指令寄存器
最后,操纵系统通过设置 CPU 的指令今存其将控制权转交给进程,跳转到 ELF 文件头中保存的入口地址。
4. 进程虚存空间分布
① ELF 文件链接视图和执行视图
操作系统装载可执行文件时,并不关心文件各个段的实际内容,而只关心装载有关的问题,主要提现为段的权限。而段的权限基本只有三种:
可读可执行:代码段为代表
可读可写:数据段与 BSS 段为代表
只读:只读数据段为代表
对于相同权限的段,操作系统将它们合并到一起作为一个段进行映射(而非以 ELF 文件中的段为单位)。这样能够有效减少页面内部碎片,节省内存空间。
-
0