《程序员的自我修养--链接装载与库》学习笔记 Part 2 静态链接
[TOC]
本文为《程序员的自我修养–链接装载与库》学习笔记 Part 2 静态链接.
第2章 编译和链接
2.1 被隐藏了的过程
预处理 (Prepressing) , 编译 (Compilation) , 汇编 (Assembly) 和链接 (Linking)
- GCC 编译过程:
2.1.1 预编译
命令
$gcc –E hello.c –o hello.i
$cpp hello.c > hello.i
主要处理那些源代码文件中的以
#
开始的预编指令. 规则如下:- 将所有的
#define
删除, 并且展开所有的宏定义. - 处理所有条件预编译指令, 比如
#if
,#ifdef
,#elif
,#else
,#endif
. - 处理
#include
预编译指令, 将被包含的文件插入到该预编译指令的位置. 注意, 这个过程是递归进行的, 也就是说被包含的文件可能还包含其他文件. - 删除所有的注释
//
和/* */
. - 添加行号和文件名标识, 比如 #2 “hello.c” 2, 以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号.
- 保留所有的
#pragma
编译器指令, 因为编译器须要使用它们.
- 将所有的
2.1.2 编译
- 把预处理完的文件进行一系列词法分析, 语法分析, 语义分析及优化后生产相应的汇编代码文件:
$gcc –S hello.i –o hello.s
. - 现在版本的 GCC 把预编译和编译两个步骤合并成一个步骤, 使用一个叫做
cc1
的程序来完成这两个步骤.$ /usr/lib/gcc/i486-linux-gnu/4.1/cc1 hello.c
- 对于 C 语言的代码来说, 这个预编译和编译的程序是
cc1
, 对于 C++ 来说, 有对应的程序叫做cc1plus
.
2.1.3 汇编
- 调用汇编器
as
:$as hello.s –o hello.o
. - gcc 命令:
$gcc –c hello.c –o hello.o
.
2.1.4 链接
- 本书主题
2.2 编译器做了什么
编译过程一般可以分为6步: 扫描, 语法分析, 语义分析, 源代码优化, 代码生成和目标代码优化.
2.2.1 词法分析
- 首先源代码程序被输入到扫描器 (Scanner) , 扫描器的任务很简单, 它只是简单地进行词法分析, 运用一种类似于有限状态机 (Finite State Machine) 的算法可以很轻松地将源代码的字符序列分割成一系列的记号 (Token) .
lex
程序可以实现词法扫描.
2.2.2 语法分析器
- 语法分析器 (Grammar Parser) 将对由扫描器产生的记号进行语法分析, 从而产生语法树 (Syntax Tree) . 整个分析过程采用了上下文无关语法 (Context-free Grammar) 的分析手段.
yacc
(Yet Another Compiler Compiler) 又被称为 “编译器编译器 (Compiler Compiler) “.
2.2.3 语义分析
语义分析器 (Semantic Analyzer)
静态语义 (Static Semantic)
- 通常包括声明和类型的匹配, 类型的转换.
动态语义 (Dynamic Semantic)
- 一般指在运行期出现的语义相关的问题, 比如将0作为除数是一个运行期语义错误.
2.2.4 中间语言生成
源码级优化器 (Source Code Optimizer)
源代码优化器往往将整个语法树转换成中间代码 (Intermediate Code) .
中间代码有很多种类型, 在不同的编译器中有着不同的形式, 比较常见的有: 三地址码 (Three-address Code) 和P-代码 (P-Code) .
中间代码使得编译器可以被分为前端和后端
- 前端负责产生机器无关的中间代码.
- 后端将中间代码转换成目标机器代码.
2.2.5 目标代码生成与优化(编译器后端)
代码生成器 (Code Generator) : 代码生成器将中间代码转换成目标机器代码.
目标代码优化器 (Target Code Optimizer) .
现代的编译器有着异常复杂的结构, 原因如下:
- 现代高级编程语言本身非常地复杂.
- 现代的计算机 CPU 相当地复杂, CPU 本身采用了诸如流水线, 多发射, 超标量等诸多复杂的特性, 为了支持这些特性, 编译器的机器指令优化过程也变得十分复杂.
- 使得编译过程更为复杂的是有些编译器支持多种硬件平台, 即允许编译器编译出多种目标 CPU 的代码.
2.3 链接器年龄比编译器长
程序并不是一写好就永远不变化的, 它可能会经常被修改. 比如我们在第1条指令之后, 第 5 条指令之前插入了一条或多条指令, 那么第 5 条指令及后面的指令的位置将会相应地往后移动, 原先第一条指令的低 4 位的数字将需要相应地调整. 在这个过程中, 程序员需要人工重新计算每个子程序或跳转的目标地址. 当程序修改的时候, 这些位置都要重新计算, 十分繁琐又耗时, 并且很容易出错. 这种重新计算各个目标的地址过程被叫做重定位 (Relocation) .
符号 (Symbol) 这个概念随着汇编语言的普及迅速被使用, 它用来表示一个地址, 这个地址可能是一段子程序 (后来发展成函数) 的起始地址, 也可以是一个变量的起始地址.
在一个程序被分割成多个模块以后, 这些模块之间最后如何组合形成一个单一的程序是须解决的问题. 模块之间如何组合的问题可以归结为模块之间如何通信的问题, 最常见的属于静态语言的C/C++模块之间通信有两种方式, 一种是模块间的函数调用, 另外一种是模块间的变量访问.
- 函数访问须知道目标函数的地址, 变量访问也须知道目标变量的地址, 所以这两种方式都可以归结为一种方式, 那就是模块间符号的引用.
- 这个模块的拼接过程就是本书的一个主题: 链接 (Linking).
2.4 模块拼装–静态链接
把各个模块之间相互引用的部分都处理好, 使得各个模块之间能够正确地衔接.
从原理上来讲, 它的工作无非就是把一些指令对其他符号地址的引用加以修正.
主要包括地址和空间分配 (Address and Storage Allocation), 符号决议 (Symbol Resolution) 和重定位 (Relocation).
最基本的静态链接过程
每个模块的源代码文件 (如
.c
) 文件经过编译器编译成目标文件 (Object File, 一般扩展名为.o
或.obj
) .目标文件和库 (Library) 一起链接形成最终可执行文件.
最常见的库就是运行时库 (Runtime Library) , 它是支持程序运行的基本函数的集合. 库其实是一组目标文件的包, 就是一些最常用的代码编译成目标文件后打包存放.
地址修正的过程也被叫做重定位 (Relocation) , 每个要被修正的地方叫一个重定位入口 (Relocation Entry) . 重定位所做的就是给程序中每个这样的绝对地址引用的位置 “打补丁” , 使它们指向正确的地址.
第3章 目标文件里有什么
3.1 目标文件的格式
种类
Windows 下的 PE (Portable Executable)
Linux 的 ELF (Executable Linkable Format)
上面都是 COFF (Common file format) 格式的变种
不太常见
- Intel/Microsoft 的 OMF (Object Module Format) , Unix a.out 格式和 MS-DOS .COM格式等
动态链接库 (DLL, Dynamic Linking Library) ( Windows 的
.dll
和 Linux 的.so
) 及静态链接库 (Static Linking Library) (Windows 的.lib
和 Linux 的.a
) 文件都按照可执行文件格式存储.- 静态链接库: 把很多目标文件捆绑在一起形成一个文件, 再加上一些索引, 可以简单地把它理解为一个包含有很多目标文件的文件包.
ELF 文件标准里面把系统中采用 ELF 格式的文件归为如表 3-1 所列举的 4 类.
Linux 下使用
file
命令来查看相应的文件格式.目标文件与可执行文件格式的小历史
- COFF 是由 Unix System V Release 3 首先提出并且使用的格式规范, 后来微软公司基于 COFF 格式, 制定了 PE 格式标准, 并将其用于当时的 Windows NT 系统. System V Release 4 在 COFF 的基础上引入了 ELF 格式, 目前流行的 Linux 系统也以 ELF 作为基本可执行文件格式.
- Unix 最早的可执行文件格式为 a.out 格式, 它的设计非常地简单, 以至于后来共享库这个概念出现的时候, a.out 格式就变得捉襟见肘了. 于是人们设计了 COFF 格式来解决这些问题, 这个设计非常通用, 以至于 COFF 的继承者到目前还在被广泛地使用.
- COFF 的主要贡献是在目标文件里面引入了 “段” 的机制, 不同的目标文件可以拥有不同数量及不同类型的 “段” . 另外, 它还定义了调试数据格式.
3.2 目标文件是什么样的
目标文件将这些信息按不同的属性, 以 节 (Section) 的形式存储, 有时候也叫 段 (Segment).
代码段 (Code Section)
- 编译后的机器指令
-
.code
或.text
数据段 (Data Section)
- 全局变量和局部静态变量数据
.data
程序与目标文件的对应示例
ELF 文件的开头是一个 文件头
- 描述了整个文件的文件属性, 包括文件是否可执行, 是静态链接还是动态链接及入口地址 (如果是可执行文件) , 目标硬件, 目标操作系统等信息.
- 文件头还包括一个 段表 (Section Table) , 段表其实是一个描述文件中各个段的数组. 段表描述了文件中各个段在文件中的偏移位置及段的属性等, 从段表里面可以得到每个段的所有信息.
BSS段
- 未初始化的全局变量和局部静态变量.
- BSS (Block Started by Symbol) 这个词最初是 UA-SAP 汇编器 (United Aircraft Symbolic Assembly Program) 中的一个伪指令, 用于为符号预留一块内存空间. 该汇编器由美国联合航空公司于 20 世纪 50 年代中期为 IBM 704 大型机所开发. 后来 BSS 这个词被作为关键字引入到了 IBM 709 和 7090/94 机型上的标准汇编器 FAP (Fortran Assembly Program) , 用于定义符号并且为该符号预留给定数量的未初始化空间.
数据和指令分段的好处
- 可以防止程序的指令被有意或无意地改写.
- CPU 的缓存命中率提高有好处. 指令区和数据区的分离有利于提高程序的局部性.
- 共享指令节省内存.
3.3 挖掘 SimpleSection.o
- 示例代码
1 | /* |
有一个专门的命令叫做
size
, 它可以用来查看 ELF 文件的代码段, 数据段和 BSS 段的长度 (dec 表示 3 个段长度的和的十进制, hex 表示长度和的十六进制)3.3.1 代码段
objdump
的-s
参数可以将所有段的内容以十六进制的方式打印出来 ,-d
参数可以将所有包含指令的段反汇编.
3.3.2 数据段和只读数据段
- 在调用
printf
的时候, 用到了一个字符串常量%d\n
, 它是一种只读数据, 所以它被放到了.rodata
段, 我们可以从输出结果看到.rodata
这个段的 4 个字节刚好是这个字符串常量的 ASCII 字节序, 最后以\0
结尾. - 有时候编译器会把字符串常量放到
.data
段, 而不会单独放在.rodata
段. - CPU 的 字节序 (Byte Order) 的问题, 也就是所谓的大端 (Big-endian) 和小端 (Little-endian) 的问题.
3.3.3 BSS段
- 有些编译器会将全局的未初始化变量存放在目标文件
.bss
段, 有些则不存放, 只是预留一个未定义的全局变量符号, 等到最终链接成可执行文件的时候再在.bss
段分配空间. - 未初始化的都是 0, 所以被优化掉了可以放在
.bss
, 这样可以节省磁盘空间, 因为.bss
不占磁盘空间.
3.3.4 其他段
这些段的名字都是由
.
作为前缀, 表示这些表的名字是系统保留的, 应用程序也可以使用一些非系统保留的名字作为段名.一个 ELF 文件也可以拥有几个相同段名的段, 比如一个 ELF 文件中可能有两个或两个以上叫做
.text
的段.如果我们要将一个二进制文件, 比如图片, MP3 音乐, 词典一类的东西作为目标文件中的一个段, 该怎么做?
- 可以使用
objcopy
工具
- 可以使用
自定义段
- 比如为了满足某些硬件的内存和 I/O 的地址布局, 或者是像 Linux 操作系统内核中用来完成一些初始化和用户空间复制时出现页错误异常等.
- GCC 提供了一个扩展机制, 使得程序员可以指定变量所处的段: 在全局变量或函数之前加上
__ attribute__((section( "name" )))
属性就可以把相应的变量或函数放到以name
作为段名的段中.
3.3 ELF文件结构描述
3.4.1 文件头
整个文件的文件属性, 包括文件是否可执行, 是静态链接还是动态链接及入口地址 (如果是可执行文件) , 目标硬件, 目标操作系统等信息, 文件头还包括一个段表 (Section Table).
ELF 的文件头中定义了 ELF 魔数, 文件机器字节长度, 数据存储方式, 版本, 运行平台, ABI 版本, ELF 重定位类型, 硬件平台, 硬件平台版本, 入口地址, 程序头入口和长度, 段表的位置和长度及段的数量等.
ELF 文件头结构及相关常数被定义在
/usr/include/elf.h
里- 32 位版本的文件头结构
Elf32_Ehdr
作为例子来描述
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16typedef 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;- 32 位版本的文件头结构
Elf32_Ehdr
和Elf64_Ehdr
: 32 位版本与 64 位版本.魔数
最开始的 4 个字节是所有 ELF 文件都必须相同的标识码, 分别为
0x7F, 0x45, 0x4c, 0x46
, 第一个字节对应 ASCII 字符里面的DEL
控制符, 后面 3 个字节刚好是 ELF 这 3 个字母的 ASCII 码. 这 4 个字节又被称为 ELF 文件的魔数, 几乎所有的可执行文件格式的最开始的几个字节都是魔数.- 操作系统在加载可执行文件的时候会确认魔数是否正确, 如果不正确会拒绝加载.
接下来的一个字节是用来标识 ELF 的文件类的,
0x01
表示是 32 位的,0x02
表示是 64 位的.第 6 个字是字节序, 规定该 ELF 文件是大端的还是小端的.
第 7 个字节规定 ELF 文件的主版本号, 一般是 1, 因为 ELF 标准自 1.2 版以后就再也没有更新了.
后面的 9 个字节 ELF 标准没有定义, 一般填 0, 有些平台会使用这 9 个字节作为扩展标志.
a.out 格式的魔数为
0x01, 0x07
, 为什么会规定这个魔数呢?- UNIX 早年是在 PDP 小型机上诞生的, 当时的系统在加载一个可执行文件后直接从文件的第一个字节开始执行, 人们一般在文件的最开始放置一条跳转 (
jump
) 指令, 这条指令负责跳过接下来的 7 个机器字的文件头到可执行文件的真正入口. 而0x01 0x07
这两个字节刚好是当时 PDP-11 的机器的跳转 7 个机器字的指令. 为了跟以前的系统保持兼容性, 这条跳转指令被当作魔数一直被保留到了几十年后的今天.
- UNIX 早年是在 PDP 小型机上诞生的, 当时的系统在加载一个可执行文件后直接从文件的第一个字节开始执行, 人们一般在文件的最开始放置一条跳转 (
文件类型
e_type
成员表示 ELF 文件类型- 统通过这个常量来判断 ELF 的真正文件类型, 而不是通过文件的扩展名.
e_machine
成员就表示该 ELF 文件的平台属性
3.4.2 段表 (Section Header Table)
每个段的段名, 段的长度, 在文件中的偏移, 读写权限及段的其他属性.
以
Elf32_Shdr
结构体为元素的数组. 数组元素的个数等于段的个数, 每个Elf32_Shdr
结构体对应一个段.Elf32_Shdr
又被称为段描述符 (Section Descriptor).ELF 段表的这个数组的第一个元素是无效的段描述符, 它的类型为
NULL
.被定义在
/usr/include/elf.h
Elf32_Shdr
段描述符结构段的属性
段的类型 (
sh_type
)段的标志位 (
sh_flags
)链接信息 (
sh_link
,sh_info
)对于系统保留段, 表列举了它们的属性.
3.4.3 重定位表
.rel.text
的段, 它的类型 (sh_type
) 为SHT_REL
, 也就是说它是一个重定位表 (Relocation Table) ..rel.text
就是针对.text
段的重定位表.它的
sh_link
表示符号表的下标, 它的sh_info
表示它作用于哪个段. 比如.rel.text
作用于.text
段, 而.text
段的下标为1
, 那么.rel.text
的sh_info
为1
.3.4.4 字符串表
因为字符串的长度往往是不定的, 所以用固定的结构来表示它比较困难. 一种很常见的做法是把字符串集中起来存放到一个表, 然后使用字符串在表中的偏移来引用字符串.
一般字符串表在 ELF 文件中也以段的形式保存, 常见的段名为
.strtab
或.shstrtab
. 这两个字符串表分别为字符串表 (String Table) 和段表字符串表 (Section Header String Table) .e_shstrndx
就表示.shstrtab
在段表中的下标, 即段表字符串表在段表中的下标. “Section header string table index” 的缩写.
3.5 链接的接口–符号
3.5.0 Intro
称目标文件 A 定义 (Define) 了函数
foo
, 称目标文件 B 引用 (Reference) 了目标文件 A 中的函数 foo . 这两个概念也同样适用于变量.在链接中, 我们将函数和变量统称为符号 (Symbol) , 函数名或变量名就是符号名 (Symbol Name) .
在链接中, 目标文件之间相互拼合实际上是目标文件之间对地址的引用, 即对函数和变量的地址的引用.
函数和变量统称为符号 (Symbol) , 函数名或变量名就是符号名 (Symbol Name).
- 符号表 (Symbol Table) , 这个表里面记录了目标文件中所用到的所有符号.
- 每个定义的符号有一个对应的值, 叫做符号值 (Symbol Value) , 对于变量和函数来说, 符号值就是它们的地址.
st_value
表示该符号在段中的偏移.
分类
- 定义在本目标文件的全局符号, 可以被其他目标文件引用.
- 在本目标文件中引用的全局符号, 却没有定义在本目标文件, 这一般叫做外部符号 (External Symbol).
- 段名, 这种符号往往由编译器产生, 它的值就是该段的起始地址.
- 局部符号, 这类符号只在编译单元内部可见. 这些局部符号对于链接过程没有作用, 链接器往往也忽略它们.
- 行号信息, 即目标文件指令与源代码中代码行的对应关系, 它也是可选的.
很多工具来查看 ELF 文件的符号表, 比如
readelf
,objdump
,nm
等.
3.5.1 ELF 符号表结构
符号表往往是文件中的一个段, 段名一般叫
.symtab
Elf32_Sym
结构 (32 位 ELF 文件) 的数组
符号类型和绑定信息 (
st_info
)- 该成员低 4 位表示符号的类型 ( Symbol Type ) , 高 28 位表示符号绑定信息 ( Symbol Binding ).
符号所在段 (
st_shndx
)- 如果符号定义在本目标文件中, 那么这个成员表示符号所在的段在段表中的下标. 但是如果符号不是定义在本目标文件中, 或者对于有些特殊符号,
sh_shndx
的值有些特殊.
- 如果符号定义在本目标文件中, 那么这个成员表示符号所在的段在段表中的下标. 但是如果符号不是定义在本目标文件中, 或者对于有些特殊符号,
符号值 (
st_value
)- 在目标文件中, 如果是符号的定义并且该符号不是
COMMON块
类型的, 则st_value
表示该符号在段中的偏移. 即符号所对应的函数或变量位于由st_shndx
指定的段, 偏移st_value
的位置. - 在目标文件中, 如果符号是
COMMON块
类型的 (即st_shndx
为SHN_COMMON
) , 则st_value
表示该符号的对齐属性. - 在可执行文件中,
st_value
表示符号的虚拟地址. 这个虚拟地址对于动态链接器来说十分有用.
- 在目标文件中, 如果是符号的定义并且该符号不是
3.5.2 特殊符号
ld
作为链接器来链接生产可执行文件时, 它会为我们定义很多特殊的符号, 这些符号并没有在你的程序中定义, 但是你可以直接声明并且引用它, 我们称之为特殊符号.注意, 只有使用
ld
链接生产最终可执行文件的时候这些符号才会存在.几个很具有代表性的特殊符号
__executable_start
, 该符号为程序起始地址, 注意, 不是入口地址, 是程序的最开始的地址.__etext
或_etext
或etext
, 该符号为代码段结束地址, 即代码段最末尾的地址._edata
或edata
, 该符号为数据段结束地址, 即数据段最末尾的地址._end
或end
, 该符号为程序结束地址.
我们可以在程序中直接使用这些符号.
3.5.3 符号修饰与函数签名
加下划线
- 减少多种语言目标文件之间的符号冲突的概率.
- GCC 编译器中, 默认情况下已经去掉了在 C 语言符号前加
_
的这种方式. 但是 Windows 平台下的编译器还保持的这样的传统, 比如 Visual C++ 编译器就会在 C 语言符号前加_
, GCC 在 Windows 平台下的版本 (cygwin, mingw) 也会加_
. - GCC 编译器也可以通过参数选项
-fleading-underscore
或-fno-leading-underscore
来打开和关闭是否在 C 语言符号前加上下划线.
C++ 符号修饰
符号修饰 (Name Decoration) 或符号改编 (Name Mangling) 的机制
函数签名 (Function Signature)
- 包括函数名, 它的参数类型, 它所在的类和名称空间及其他信息.
- 在编译器及链接器处理符号时, 它们使用某种名称修饰的方法, 使得每个函数签名对应一个修饰后名称 (Decorated Name) .
C++ 中的全局变量和静态变量也有同样的机制.
不同的编译器厂商的名称修饰方法可能不同.
binutils
里面提供了一个叫c++filt
的工具可以用来解析被修饰过的名称.
3.5.4 extern “C”
很多时候我们会碰到有些头文件声明了一些 C 语言的函数和全局变量, 但是这个头文件可能会被 C 语言代码或 C++ 代码包含. 但是在 C++ 语言中, 编译器会认为这个
memset
函数是一个 C++ 函数, 将memset
的符号修饰成_Z6memsetPvii
, 这样链接器就无法与 C 语言库中的memset
符号进行链接. 所以对于 C++ 来说, 必须使用 extern “C” 来声明memset
这个函数. 但是 C 语言又不支持 extern “C” 语法, 如果为了兼容 C 语言和 C++ 语言定义两套头文件, 未免过于麻烦. 幸好我们有一种很好的方法可以解决上述问题, 就是使用 C++ 的宏__cplusplus
.C++ 编译器会在编译 C++ 的程序时默认定义这个宏, 我们可以使用条件宏来判断当前编译单元是不是 C++ 代码.
1
2
3
4
5
6
7
8
9
extern "C" {
void *memset (void *, int, size_t);
}- 如果当前编译单元是 C++ 代码, 那么
memset
会在 extern “C” 里面被声明. 如果是 C 代码, 就直接声明.
- 如果当前编译单元是 C++ 代码, 那么
3.5.5 弱符号与强符号
问题: 多个目标文件中含有相同名字全局符号的定义.
区分
- 编译器默认函数和初始化了的全局变量为强符号, 未初始化的全局变量为弱符号.
- 也可以通过 GCC 的
__attribute__((weak))
来定义任何一个强符号为弱符号.
链接器规则
- 规则1: 不允许强符号被多次定义 (即不同的目标文件中不能有同名的强符号) . 如果有多个强符号定义, 则链接器报符号重复定义错误.
- 规则2: 如果一个符号在某个目标文件中是强符号, 在其他文件中都是弱符号, 那么选择强符号.
- 规则3: 如果一个符号在所有目标文件中都是弱符号, 那么选择其中占用空间最大的一个.
弱引用和强引用
- 如果没有找到该符号的定义, 链接器就会报符号未定义错误, 这种被称为强引用 (Strong Reference) .
- 在处理弱引用时, 如果该符号有定义, 则链接器将该符号的引用决议. 如果该符号未被定义, 则链接器对于该引用不报错. 链接器处理强引用和弱引用的过程几乎一样, 只是对于未定义的弱引用, 链接器不认为它是一个错误. 一般对于未定义的弱引用, 链接器默认其为 0, 或者是一个特殊的值, 以便于程序代码能够识别.
- 在 GCC 中, 我们可以通过使用
__attribute__((weakref))
这个扩展关键字来声明对一个外部函数的引用为弱引用.
1
2
3
4
5__attribute__ ((weakref)) void foo();
int main()
{
if(foo) foo();
}应用
如果一个程序被设计成可以支持单线程或多线程的模式, 就可以通过弱引用的方法来判断当前的程序是链接到了单线程的 Glibc 库还是多线程的 Glibc 库 (是否在编译时有
-lpthread
选项)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int pthread_create(
pthread_t*,
const pthread_attr_t*,
void* (*)(void*),
void*) __attribute__ ((weak));
int main()
{
if(pthread_create) {
printf("This is multi-thread version!\n");
// run the multi-thread version
// main_multi_thread()
} else {
printf("This is single-thread version!\n");
// run the single-thread version
// main_single_thread()
}
}这种弱符号和弱引用对于库来说十分有用
- 库中定义的弱符号可以被用户定义的强符号所覆盖, 从而使得程序可以使用自定义版本的库函数.
- 程序可以对某些扩展功能模块的引用定义为弱引用, 当我们将扩展模块与程序链接在一起时, 功能模块就可以正常使用. 如果我们去掉了某些功能模块, 那么程序也可以正常链接, 只是缺少了相应的功能, 这使得程序的功能更加容易裁剪和组合.
3.6 调试信息
前提是编译器必须提前将源代码与目标代码之间的关系.
- 目标代码中的地址对应源代码中的哪一行, 函数和变量的类型, 结构体的定义, 字符串保存到目标文件里面.
GCC 编译时加上
-g
参数, 编译器就会在产生的目标文件里面加上调试信息.ELF 文件采用一个叫 DWARF (Debug With Arbitrary Record Format) 的标准的调试信息格式.
Microsoft 也有自己相应的调试信息格式标准, 叫 CodeView.
调试信息在目标文件和可执行文件中占用很大的空间, 往往比程序的代码和数据本身大好几倍, 所以当我们开发完程序并要将它发布的时候, 须要把这些对于用户没有用的调试信息去掉, 以节省大量的空间.
在 Linux 下, 我们可以使用
strip
命令来去掉 ELF 文件中的调试信息.
第4章 静态链接
4.1 空间与地址分配
对于链接器来说, 整个链接过程中, 它就是将几个输入目标文件加工后合并成一个输出文件.
4.1.1 按序叠加
比如对于 x86 的硬件来说, 段的装载地址和空间的对齐单位是页, 也就是 4096 字节. 如果一个段的长度只有 1 个字节, 它也要在内存中占用 4096 字节. 这样会造成内存空间大量的内部碎片.
4.1.2 相似段合并
“链接器为目标文件分配地址和空间” 这句话中的 “地址和空间” 其实有两个含义: 第一个是在输出的可执行文件中的空间. 第二个是在装载后的虚拟地址中的虚拟地址空间.
- 对于有实际数据的段, 比如
.text
和.data
来说, 它们在文件中和虚拟地址中都要分配空间, 因为它们在这两者中都存在. - 而对于 “.bss” 这样的段来说, 分配空间的意义只局限于虚拟地址空间, 因为它在文件中并没有内容.
- 事实上, 我们在这里谈到的空间分配只关注于虚拟地址空间的分配, 因为这个关系到链接器后面的关于地址计算的步骤, 而可执行文件本身的空间分配与链接过程关系并不是很大.
- 对于有实际数据的段, 比如
两步链接 (Two-pass Linking) 的方法
第一步 空间与地址分配
- 扫描所有的输入目标文件, 并且获得它们的各个段的长度, 属性和位置, 并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来, 统一放到一个全局符号表. 这一步中, 链接器将能够获得所有输入目标文件的段长度, 并且将它们合并, 计算出输出文件中各个段合并后的长度与位置, 并建立映射关系.
第二步 符号解析与重定位
- 使用上面第一步中收集到的所有信息, 读取输入文件中段的数据, 重定位信息, 并且进行符号解析与重定位, 调整代码中的地址等. 事实上第二步是链接过程的核心, 特别是重定位过程.
VMA 表示 Virtual Memory Address, 即虚拟地址, LMA 表示 Load Memory Address, 即加载地址, 正常情况下这两个值应该是一样的, 但是在有些嵌入式系统中, 特别是在那些程序放在 ROM 的系统中时, LMA 和 VMA 是不相同的.
操作系统的进程虚拟地址空间的分配规则, 在 Linux 下, ELF 可执行文件默认从地址
0x08048000
开始分配.
4.1.3 符号地址的确定
- 链接器须要给每个符号加上一个偏移量, 使它们能够调整到正确的虚拟地址.
4.2 符号解析与重定位
4.2.1 重定位
- 编译器把引用函数与变量的地址部分暂时用地址
0x00000000
和0xFFFFFFFC
代替着, 把真正的地址计算工作留给了链接器.
4.2.2 重定位表
重定位表 (Relocation Table) 的结构专门用来保存这些与重定位相关的信息.
- 哪些指令是要被调整的呢? 这些指令的哪些部分要被调整? 怎么调整?
- 对于每个要被重定位的 ELF 段都有一个对应的重定位表, 而一个重定位表往往就是 ELF 文件中的一个段, 所以其实重定位表也可以叫重定位段, 我们在这里统一称作重定位表.
每个要被重定位的地方叫一个重定位入口 (Relocation Entry).
重定位入口的偏移 (Offset) 表示该入口在要被重定位的段中的位置.
重定位表的结构
32 位的 Intel x86 系列处理器
一个
Elf32_Rel
结构的数组, 每个数组元素对应一个重定位入口.
4.2.3 符号解析
- 链接器就会去查找由所有输入目标文件的符号表组成的全局符号表, 找到相应的符号后进行重定位.
-
UND
, 即 undefined 未定义类型, 所有这些未定义的符号都应该能够在全局符号表中找到, 否则链接器就报符号未定义错误.
4.2.4 指令修正方式
不同的处理器指令对于地址的格式和方式都不一样.
Intel x86 系列 CPU 的
jmp
指令有 11 种寻址模式.call
指令有 10 种.mov
指令则有多达 34 种寻址模式.分类
- 近址寻址或远址寻址.
- 绝对寻址或相对寻址.
- 寻址长度为 8 位, 16 位, 32 位或 64 位.
对于 32 位 x86 平台下的 ELF 文件的重定位入口所修正的指令寻址方式只有两种:
- 绝对近址 32 位寻址.
- 相对近址 32 位寻址.
- A = 保存在被修正位置的值.
- P = 被修正的位置 (相对于段开始的偏移量或者虚拟地址) , 注意, 该值可通过
r_offset
计算得到. - S = 符号的实际地址, 即由
r_info
的高 24 位指定的符号的实际地址.
- P = 被修正的位置 (相对于段开始的偏移量或者虚拟地址) , 注意, 该值可通过
绝对寻址修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址. 相对寻址修正后的地址为符号距离被修正位置的地址差.
4.3 COMMON 块
变量类型对于链接器来说是透明的, 它只知道一个符号的名字, 并不知道类型是否一致.
如果链接过程中有弱符号大小大于强符号, 那么
ld
链接器会报如下警告:ld: warning: alignment 4 of symbol 'global' in a.o is smaller than 8 in b.o
.这种使用 COMMON 块的方法实际上是一种类似 “黑客” 的取巧办法, 直接导致需要 COMMON 机制的原因是编译器和链接器允许不同类型的弱符号存在, 但最本质的原因还是链接器不支持符号类型, 即链接器无法判断各个符号的类型是否一致.
在目标文件中, 编译器为什么不直接把未初始化的全局变量也当作未初始化的局部静态变量一样处理, 为它在 BSS 段分配空间, 而是将其标记为一个COMMON类型的变量?
- 当编译器将一个编译单元编译成目标文件的时候, 如果该编译单元包含了弱符号 (未初始化的全局变量就是典型的弱符号) , 那么该弱符号最终所占空间的大小在此时是未知的, 因为有可能其他编译单元中该符号所占的空间比本编译单元该符号所占的空间要大. 所以编译器此时无法为该弱符号在 BSS 段分配空间, 因为所须要空间的大小未知. 但是链接器在链接过程中可以确定弱符号的大小, 因为当链接器读取所有输入目标文件以后, 任何一个弱符号的最终大小都可以确定了, 所以它可以在最终输出文件的 BSS 段为其分配空间. 所以总体来看, 未初始化全局变量最终还是被放在 BSS 段的.
一旦一个未初始化的全局变量不是以 COMMON 块的形式存在, 那么它就相当于一个强符号.
- GCC 的
-fno-common
也允许我们把所有未初始化的全局变量不以 COMMON 块的形式处理, 或者使用__ attribute __
扩展:int global __attribute__((nocommon));
.
- GCC 的
4.4 C++ 相关问题
4.4.0 Intro
C++ 的一些语言特性使之必须由编译器和链接器共同支持才能完成工作. 最主要的有两个方面:
- 一个是 C++ 的重复代码消除.
- 还有一个就是全局构造与析构.
4.4.1 重复代码消除
模板 (Templates) , 外部内联函数 (Extern Inline Function) 和虚函数表 (Virtual Function Table) 都有可能在不同的编译单元里生成相同的代码.
将这些重复的代码都保留下来. 不过这样做的主要问题有以下几方面.
空间浪费
地址较易出错
- 有可能两个指向同一个函数的指针会不相等.
指令运行效率较低
- 因为现代的 CPU 都会对指令和数据进行缓存, 如果同样一份指令有多份副本, 那么指令 Cache 的命中率就会降低.
一个比较有效的做法就是将每个模板的实例代码都单独地存放在一个段里, 每个段只包含一个模板实例.
GCC 把这种类似的须要在最终链接时合并的段叫
Link Once
, 它的做法是将这种类型的段命名为.gnu.linkonce.name
, 其中name
是该模板函数实例的修饰后名称.这种方法虽然能够基本上解决代码重复的问题, 但还是存在一些问题.
- 相同名称的段可能拥有不同的内容, 这可能由于不同的编译单元使用了不同的编译器版本或者编译优化选项, 导致同一个函数编译出来的实际代码有所不同. 那么这种情况下链接器可能会做出一个选择, 那就是随意选择其中任何一个副本作为链接的输入, 然后同时提供一个警告信息.
函数级别链接 (Functional-Level Linking, /Gy) 编译选项
让所有的函数都像前面模板函数一样, 单独保存到一个段里面. 当链接器须要用到某个函数时, 它就将它合并到输出文件中, 对于那些没有用的函数则将它们抛弃.
副作用
- 减慢编译和链接过程, 因为链接器须要计算各个函数之间的依赖关系.
- 目标函数的段的数量大大增加, 重定位过程也会因为段的数目的增加而变得复杂, 目标文件随着段数目的增加也会变得相对较大.
GCC 编译器也提供了类似的机制, 它有两个选择分别是
-ffunction-sections
和-fdata-sections
, 这两个选项的作用就是将每个函数或变量分别保持到独立的段中.
4.4.2 全局构造与析构
C++ 的全局对象的构造函数在 main 之前被执行, C++ 全局对象的析构函数在 main 之后被执行.
Linux 系统下一般程序的入口是
_start
, 这个函数是 Linux 系统库 (Glibc) 的一部分. 当我们的程序与 Glibc 库链接在一起形成最终可执行文件以后, 这个函数就是程序的初始化部分的入口, 程序初始化部分完成一系列初始化过程之后, 会调用 main 函数来执行程序的主体. 在 main函数执行完成以后, 返回到初始化部分, 它进行一 些清理工作, 然后结束进程. 因此 ELF 文件还定义了两种特殊的段..init
- 该段里面保存的是可执行指令, 它构成了进程的初始化代码. 因此, 当一个程序开始运行时, 在 main 函数被调用之前, Glibc 的初始化部分安排执行这个段的中的代码.
.fini
- 该段保存着进程终止代码指令. 因此, 当一个程序的 main 函数正常退出时, Glibc 会安排执行这个段中的代码.
4.4.3 C++ 与 ABI
如果要使两个编译器编译出来的目标文件能够相互链接, 那么这两个目标文件必须满足下面这些条件: 采用同样的目标文件格式, 拥有同样的符号修饰标准, 变量的内存分布方式相同, 函数的调用方式相同, 等等.
符号修饰标准, 变量内存布局, 函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为 ABI (Application Binary Interface).
ABI 是指二进制层面的接口, ABI 的兼容程度比 API 要更为严格.
于 C 语言的目标代码来说, 以下几个方面会决定目标文件之间是否二进制兼容.
- 内置类型 (如 int, float, char 等) 的大小和在存储器中的放置方式 (大端, 小端, 对齐方式等) .
- 组合类型 (如 struct, union, 数组等) 的存储方式和内存分布.
- 外部符号 (external-linkage) 与用户定义的符号之间的命名方式和解析方式, 如函数名
func
在 C 语言的目标文件中是否被解析成外部符号_func
. - 函数调用方式, 比如参数入栈顺序, 返回值如何保持等.
- 堆栈的分布方式, 比如参数和局部变量在堆栈里的位置, 参数传递方法等.
- 寄存器使用约定, 函数调用时哪些寄存器可以修改, 哪些须要保存, 等等.
C++ 一直为人诟病的一大原因是它的二进制兼容性不好.
C++ 特有的难点
- 继承类体系的内存分布, 如基类, 虚基类在继承类中的位置等.
- 指向成员函数的指针 (pointer-to-member) 的内存分布, 如何通过指向成员函数的指针来调用成员函数, 如何传递 this 指针.
- 如何调用虚函数, vtable 的内容和分布形式, vtable 指针在 object 中的位置等.
- template 如何实例化.
- 外部符号的修饰.
- 全局对象的构造和析构.
- 异常的产生和捕获机制.
- 标准库的细节问题, RTTI 如何实现等.
- 内嵌函数访问细节.
不仅不同的编译器编译的二进制代码之间无法相互兼容, 有时候连同一个编译器的不同版本之间兼容性也不好.
基本形成以微软的 VISUAL C++ 和 GNU 阵营的 GCC (采用 Intel Itanium C++ ABI 标准) 为首的两大派系.
4.5 静态库链接
一个静态库可以简单地看成一组目标文件的集合, 即很多目标文件经过压缩打包后形成的一个文件.
libc.a 静态库文件
- glibc 本身是用 C 语言开发的, 它由成百上千个 C 语言源代码文件组成.
ar
压缩程序将这些目标文件压缩到一起, 并且对其进行编号和索引, 以便于查找和检索, 就形成了 libc.a 这个静态库文件.
静态库链接例子
打印详细过程:
$gcc -static --verbose -fno-builtin hello.c
- 第一步是调用
cc1
程序, 这个程序实际上就是 GCC 的 C 语言编译器, 它将hello.c
编译成一个临时的汇编文件/tmp/ccUhtGSB.s
. - 然后调用
as
程序,as
程序是 GNU 的汇编器, 它将/tmp/ccUhtGSB.s
汇编成临时目标文件/tmp/ccQZRPL5.o
, 这个/tmp/ccQZRPL5.o
实际上就是前面的hello.o
. - 最后一步, GCC 调用
collect2
程序来完成最后的链接. 可以简单地把collect2
看作是ld
链接器.
- 第一步是调用
4.6 链接过程控制
4.6.0 Intro
背景: 受限于一些特殊的条件, 如须要指定输出文件的各个段虚拟地址, 段的名称, 段存放的顺序等, 因为这些特殊的环境, 特别是某些硬件条件的限制, 往往对程序的各个段的地址有着特殊的要求.
- 操作系统内核, BIOS (Basic Input Output System) 或一些在没有操作系统的情况下运行的程序 (如引导程序 Boot Loader 或者嵌入式系统的程序.
- 或者有一些脱离操作系统的硬盘分区软件 PQMagic 等) .
- 以及另外的一些须要特殊的链接过程的程序, 如一些内核驱动程序等.
问题: 由于整个链接过程有很多内容须要确定: 使用哪些目标文件? 使用哪些库文件? 是否在最终可执行文件中保留调试信息, 输出文件格式 (可执行文件还是动态链接库) ? 还要考虑是否要导出某些符号以供调试器或程序本身或其他程序使用等.
4.6.1 使用链接控制脚本
一般链接器有如下三种方法
使用命令行来给链接器指定参数.
将链接指令存放在目标文件里面, 编译器经常会通过这种方法向链接器传递指令.
- 比如 VISUAL C++ 编译器会把链接参数放在 PE 目标文件的
.drectve
段以用来传递参数.
- 比如 VISUAL C++ 编译器会把链接参数放在 PE 目标文件的
使用链接控制脚本, 也是最为灵活, 最为强大的链接控制方法.
VISUAL C++ 把这种控制脚本叫做模块定义文件 (Module-Definition File) , 它们的扩展名一般为
.def
.查看
ld
默认的链接脚本:$ ld -verbose
.ld
链接脚本存放在/usr/lib/ldscripts/
.
4.6.2 最 “小” 的程序
code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23char* str = "Hello world!\n";
void print()
{
asm( "movl $13,%%edx \n\t"
"movl %0,%%ecx \n\t"
"movl $0,%%ebx \n\t"
"movl $4,%%eax \n\t"
"int $0x80 \n\t"
::"r"(str):"edx","ecx","ebx");
}
void exit()
{
asm( "movl $42,%ebx \n\t"
"movl $1,%eax \n\t"
"int $0x80 \n\t" );
}
void nomain()
{
print();
exit();
}程序入口为
nomain()
函数, 然后该函数调用print()
函数, 打印Hello World
, 接着调用exit()
函数, 结束进程.print()
函数使用了 Linux 的WRITE
系统调用,exit()
函数使用了EXIT
系统调用.使用了 GCC 内嵌汇编.
系统调用通过
0x80
中断实现, 其中eax
为调用号,ebx
,ecx
,edx
等通用寄存器用来传递参数.比如
WRITE
调用是往一个文件句柄写入数据, 如果用 C 语言来描述它的原型就是1
int write(int filedesc, char* buffer, int size);
WRITE
调用的调用号为 4, 则eax = 0
.filedesc
表示被写入的文件句柄, 使用ebx
寄存器传递, 我们这里是要往默认终端 (stdout
) 输出, 它的文件句柄为 0, 即ebx = 0
.buffer
表示要写入的缓冲区地址, 使用ecx
寄存器传递, 我们这里要输出字符串str
, 所以ecx = str
.size
表示要写入的字节数, 使用edx
寄存器传递, 字符串Hello world!\n
长度为 13 字节, 所以edx = 13
.
EXIT
系统调用中,ebx
表示进程退出码 (Exit Code
) .EXIT
系统调用的调用号为 1 , 即eax = 1
.
先使用普通命令行的方式来编译和链接
TinyHelloWorld.c
:1
2$ gcc -c -fno-builtin TinyHelloWorld.c
$ ld -static -e nomain -o TinyHelloWorld TinyHelloWorld.o参数选项
-fno-builtin
: GCC 编译器提供了很多内置函数 (Built-in Function) , 它会把一些常用的 C 库函数替换成编译器的内置函数, 以达到优化的功能. 比如 GCC 会将只有字符串参数的printf
函数替换成puts
, 以节省格式解析的时间.exit()
函数也是 GCC 的内置参数之一, 所以我们要使用-fno-builtin
参数来关闭 GCC 内置函数功能.-static
: 这个参数表示ld
将使用静态链接的方式来链接程序, 而不是使用默认的动态链接的方式.-e nomain
: 表示该程序的入口函数为nomain
, 还记得 ELF 文件头Elf32_Ehdr
的e_entry
成员吗? 这个参数就是将 ELF 文件头的e_entry
成员赋值成nomain
函数的地址.-o TinyHelloWorld
表示指定输出可执行文件名为TinyHelloWorld
.
TinyHelloWorld
这个文件有 4 个段:.text, .rodata, .data
和.comment
.鉴于这些段的属性如此相似, 原则上讲, 我们可以把它们合并到一个段里面, 该段的属性是可执行, 可读的, 包含程序的数据和指令. 为了达到这个目的, 我们必须使用
ld
链接脚本来控制链接过程.
4.6.3 使用 ld
链接脚本
无论是输出文件还是输入文件, 它们的主要的数据就是文件中的各种段, 我们把输入文件中的段称为输入段 (Input Sections) , 输出文件中的段称为输出段 (Output Sections) .
简单来讲, 控制链接过程无非是控制输入段如何变成输出段, 比如哪些输入段要合并一个输出段, 哪些输入段要丢弃. 指定输出段的名字, 装载地址, 属性, 等等.
TinyHelloWorld.lds
(一般链接脚本名都以lds
作为扩展名 ld script)1
2
3
4
5
6
7
8
9ENTRY(nomain)
SECTIONS
{
. = 0x08048000 + SIZEOF_HEADERS;
tinytext : { *(.text)*(.data)*(.rodata) }
/DISCARD/ : { *(.comment) }
}第一行的
ENTRY(nomain)
指定了程序的入口为nomain()
函数.ECTIONS
命令一般是链接脚本的主体, 这个命令指定了各种输入段到输出段的变换,SECTIONS
后面紧跟着的一对大括号里面包含了SECTIONS
变换规则, 其中有三条语句, 每条语句一行.- 第一条赋值语句的意思是将当前虚拟地址设置成
0x08048000 + SIZEOF_HEADERS , SIZEOF_HEADERS
为输出文件的文件头大小..
表示当前虚拟地址, 因为这条语句后面紧跟着输出段tinytext
, 所以tinytext
段的起始虚拟地址即为0x08048000 +SIZEOF_HEADERS
. 它将当前虚拟地址设置成一个比较巧妙的值, 以便于装载时页映射更为方便. - 第二条是个段转换规则, 它的意思即为所有输入文件中的名字为
.text
,.data
或.rodata
的段依次合并到输出文件的tinytext
. - 第三条规则为: 将所有输入文件中的名字为
.comment
的段丢弃, 不保存到输出文件中.
- 第一条赋值语句的意思是将当前虚拟地址设置成
启用该链接控制脚本
1
2$ gcc –c –fno-builtin TinyHelloWorld.c
$ ld –static –T TinyHelloWorld.lds –o TinyHelloWorld TinyHelloWorld.o程序除了
tinytext
之外居然还有其他 3 个段:.shstrtab
,.symtab
和.strtab
. 这 3 个段我们在前面已经介绍过了, 它们分别是段名字符串表, 符号表和字符串表.- 通过
ld
的-s
参数禁止链接器产生符号表. - 使用
strip
命令来去除程序中的符号表. - 段名字符串表用户保存段名, 所以它是必不可少的.
- 通过
4.6.4 ld
链接脚本语法简介
ld
链接器的链接脚本语法继承与 AT&T 链接器命令语言的语法, 风格有点像 C 语言, 它本身并不复杂. 链接脚本由一系列语句组成, 语句分两种, 一种是命令语句, 另外一种是赋值语句.
4.7 BFD 库
- BFD库 (Binary File Descriptor library) 是一个 GNU 项目, 它的目标就是希望通过一种统一的接口来处理不同的目标文件格式. BFD 把目标文件抽象成一个统一的模型.
- GCC (更具体地讲是 GNU 汇编器 GAS, GNU Assembler) , 链接器
ld
,binutils
的其他工具都通过 BFD 库来处理目标文件. 这样做最大的好处是将编译器和链接器本身同具体的目标文件格式隔离开来. - ubuntu下, 包含 BFD 开发库的软件包的名字叫
binutils-dev
.
第5章 Windows PE/COFF
略过.
《程序员的自我修养--链接装载与库》学习笔记 Part 2 静态链接
https://www.chuxin911.com/linkage_loading_lib_part2_static_linkage_20220625/