链接、装载与库
本文是《程序员的自我修养—链接、装载与库》的读书笔记,内容基本和书中一致,最大的不同是书中的例子是 32 机器的,我替换成了 64 位机器的。
基础概念
硬件
- 中央处理器 CPU
- 内存
- I/O 控制芯片
计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决
操作系统
程序运行模式
- 多道程序:人们编写了一个监控程序,当某个程序无需使用 CPU 时,监控程序就把正在等待 CPU 资源的程序启动
- 分时系统:每个程序运行一段时间后都主动让出 CPU 给其它程序,使得一段时间内每个程序都有机会运行一小段时间
- 多任务系统:操作系统接管所有的硬件资源,本身运行在受硬件保护的级别。所有的应用程序都以进程的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU 由操作系统统一分配,每个进程根据优先级的高低都有机会得到 CPU,但是,如果运行超过一定的时间,操作系统就会暂停该线程,将 CPU 资源分配给其他等待运行的进程。(这种 CPU 分配方式称为抢占式)如果操作系统分配给每个进程的时间都很短,即 CPU 在多个进程之间快速切换,从而造成很多进程在同时运行的假象。目前主流的操作系统都是采用这种方式。
1. 内存
程序运行需要内存,一般的想法是分配直接分配一片区域就好,但这样会有以下问题:
- 地址空间不隔离:所有的程序都是直接访问物理地址,程序所使用的内存空间不是相互隔离的。恶意程序可以很容易改写其他程序的内容。
- 内存使用效率低:当一个新的程序需要运行,而此时内存的剩余大小并不能满足新程序的要求。这时候就需要将其他程序数据暂时写到磁盘中,给新的程序腾出空间。这些换出换入操作都是整段整段的,并不能充分使用整个内存空间。
- 程序运行地址不确定:程序装入运行时,需要分配一个足够大的空闲区域,这个区域位置一般无法确定。这就给程序的编写造成了一定的困扰,因为程序编写时,它访问数据和指令跳转的目标地址很多是固定的。
解决方法是添加中间层:把程序给出的地址看作是一种虚拟地址,通过某种映射方法,将这个虚拟地址转换成实际的物理地址。
分段
刚开始人们使用一种分段的方法,基本思路是把程序所需要的内存空间大小的虚拟地址映射到某个地址空间。
比如程序 A 需要 10 MB 内存,那么我们假设有一个从 0x00000000 到 0x00A00000 的 10 MB 大小的虚拟空间,然后从实际物体内存中分配一个相同大小的物理地址,然后把两块相同大小的地址空间进行一一映射。
分段的操作解决了 地址空间不隔离 和 程序运行地址不确定 的问题,但是它也是整段换入换出的,所以导致内存使用效率也是低的。
分页
分页就是把地址空间人为的分成固定大小的页,每一页的大小由硬件决定或者硬件支持多种大小的页面,由操作系统决定页的大小。
目前几乎所有 PC 机上的操作系统都使用 4KB 大小的页,我们的 PC 机是 32 位虚拟空间的,也就是 4 GB,换算成页就是 4 GB / 4 KB = 1024 ^ 2 = 1048576 页。物理空间也是同样的分法。
计算机按字节寻址,按这种方式,一个地址和一个字节相应,所以 2^32 字节 = 4 GB
当我们把进程的虚拟地址空间按页分割,把常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘中,当需要用到的时候再把它从磁盘中取出来即可。
例如下面的例子,从图中可以得知进程 Process 1 和 Process 2 的虚拟空间有 8 页,而物理空间只有 6 页。 Process 1 和 Process 2 的部分虚拟页面被映射到了物理页面,例如 Process 1 的 VP0、VP1、VP7 虚拟页面映射到了物理内存的 PP0、PP2、PP3 页面,Process 1 的 VP2、VP3 页面还在磁盘中。
- 进程 Process 1 和 Process 2 的 VP7 页面都映射到了物理空间的 PP3 页面,这样就实现了内存共享。
- 当进程 Process 1 需要用到 VP2、VP3 页面时,硬件会捕捉到这个错误,这被称为页错误。然后操作系统接管进程,负责将 VP2、VP3 页面从磁盘中读出并装入物理空间中,然后将物理空间中的这两个页和 VP2、VP3 页面建立映射关系。
这里还可以补充页面更换算法和内存抖动知识
I/O
操作系统作为硬件层的上层,它是对硬件的管理和抽象。具体的讲是操作系统中的硬件驱动程序完成了这些抽象。
- 驱动程序可以看成是操作系统的一部分,它往往和操作系统内核一起运行在特权级,但它又与操作系统内核之间有一定的独立性,使得驱动程序有比较良好的灵活性。
- PC 硬件很多,操作系统不可能为每一个硬件都提供一个驱动程序。操作系统会硬件厂商提供了相关接口和框架,厂商只需要按照要求开发驱动程序即可。
线程
线程分类:
- I/O 密集型线程:频繁等待的线程
- CPU 密集型线程:很少等待的线程
I/O 密集型线程总是比 CPU 密集型线程更容易得到优先级的提升。
线程饿死:该线程优先级较低,在它执行之前,总是有较高优先级的线程要执行,因此这个线程始终无法执行。
这里只是简单介绍,详细参考 Java 并发编程解读
编译和链接
IDE 一般都将编译链接一步完成,通常将编译和链接一步完成的过程称为构建(Build)
GCC
Linux 中的 GCC 编译器是 GNU 推出的一款功能强大、性能优越的多平台编译器。它可以将 C、C++ 等源代码编译、链接成可执行文件。更多关于 GCC 的内容可以参考:http://c.biancheng.net/view/7930.html
例如编译下面的 Hello World C 程序源代码(hello.c),使用命令 gcc hello.c
就可以将 hello.c 文件编译成 a.out 文件。
1 |
|
gcc hello.c
的过程可以总结为以下步骤:
- 预编译/预处理
- 编译
- 汇编
- 链接
编译过程
预编译
预编译的主要工作是处理源代码中以 #
开始的预编译指令,主要处理规则如下:
- 将所有的
#define
删除,并且展开所有的宏定义 - 处理所有条件预编译指令,比如:
#if
、#ifdef
、#elif
、#else
、#endif
- 处理
#include
预编译指令,将被包含的文件插入到预编译指令的位置。这个过程是递归的,因为被包含的文件还可能包含其他文件。 - 删除所有的注释,
//
、/* */
- 添加行号和文件名标识,比如
#2 “hello.c” 2
,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号 - 保留所有的
#pragma
编译器指令,因为编译器需要用到它们
通过使用 gcc -E
可以只对源文件进行预编译,例如对 hello.c 进行预编译 gcc -E hello.c
:
如果想将内容预编译后的内容输入到一个文件中,可以使用命令:gcc -E hello.c -o hello.i
:
-E 表示只进行预编译
-o 表示输出到指定文件
文件格式
- .h:程序头文件
- .c:C 源代码文件
- .C/.cc/.cxx:C++ 源代码文件
- .i:预编译后的 C 源代码文件
- .ii:预编译后的 C++ 源代码文件
- .s:编译后的汇编文件
- o:汇编后的目标文件
- .out:编译后的可执行文件
编译
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件。详情参考编译原理的内容。
编译源文件可以使用命令:gcc -S
,例如对 hello.c 进行编译:gcc -S hello.i
或 gcc -S hello.c
:
汇编
汇编是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。
汇编源文件可以使用命令:gcc -c
,例如对 hello.c 进行汇编:gcc -c hello.s
或 gcc -c hello.c
:
链接
为什么汇编器不直接输出可执行文件而是输出一个目标文件,例如上面的 hello.o 文件?这个问题很大,具体会在后面的内容中揭晓。先简单说明几个概念:
链接:主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确的衔接。
链接过程主要包括:地址和空间分配、符合决议/符号绑定、重定位
重定位:例如模块 A 引用了 模块 B 的一个变量 var。由于编译的时候,编译器不知道变量 var 的地址,所以会先将目标地址置为 0,等待链接器将目标文件 A 和 B 链接起来的时候再将其修正。这个地址修正的过程就被称为重定位,每个要被修正的地方叫一个重定位入口。
目标文件
编译器编译源代码后生成的文件叫做目标文件,也就是上面经过汇编后得到的 .o
文件。
目标文件从结构上来说其实已经是编译后的可执行文件格式,只是还没有经过链接的过程。其中可能有些符号或者地址还没有被调整。
目标文件的格式
现在 PC 上流行的可执行文件格式主要是 Window 下的 PE
(Portable Executable)和 Linux 下的 ELF
(Executable Linkable Format),它们都是 COFF(Common file format)格式的变种。(目标文件在 Window 下一般是 .obj
,在 Linux 下一般是 .o
)
不光是可执行文件(Window 下的 .exe 和 Linux 下的 ELF 可执行文件)按照可执行文件格式存储。动态链接库(DLL,Dynamic Linking Library,Windows 的 .dll
和 Linux 的 .so
)以及 静态链接库(Static Linking Library,Windows 的 .lib
和 Linux 的 .a
)文件都按照可执行文件格式存储。
静态链接库稍有不同,它是把很多目标文件捆绑在一起形成一个文件,再加上一些索引。可以简单理解为一个包含很多有很多目标文件的文件包。
使用 Mac 学习目标文件格式存在一定的问题,具体可以参考:理解Mach-O文件格式,因此我在 Unix 虚拟机中学习目标文件格式:
- 其中 Unix 配置 GCC 还存在无法下载的问题,可以参考这篇文章解决:https://blog.csdn.net/Chaowanq/article/details/121559709。
- Unix 虚拟机配置 VMware Tools 可以参考这一篇文章:https://www.pianshen.com/article/90061887488/
Linux 下使用 file 命令查看文件格式:
目标文件的内容
基本概念
- 目标文件将其信息按不同的属性,以节的形式存储,也称为段
- 程序源代码编译后的机器指令常被放在代码段中,代码段的常见名有:
.text
和.code
。全局变量和局部静态变量数据经常放在数据段中,数据段的常见名是:.data
。未初始化的全局变量和局部静态变量一般存放在.bss
段中。 - 目标文件 ELF 的开头是一个文件头,它描述了整个文件的属性,包括文件是否可执行、是静态链接还是动态链接、入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息。文件头还包括一个段表,段表其实是一个描述文件中各个段的数组。段表表述了文件中各个段在文件中的偏移位置以及段的属性等,从段表里面可以得到每个段的所有信息。文件头后就是各个段的内容,例如代码段、数据段。
为什么要有 .bss 段?
未初始化的全局变量和局部静态变量默认值都为 0,本来它们也可以放在 .data 段的,但因为它们都是 0,所以为它们在 .data 段分配空间并且存放数据 0 是没有必要的。.bss 段只是为未初始化的全局变量和局部静态变量预留位置而已。
为什么指令和数据要分开存储?
1.当程序被装载后,指令和数据被映射到两个虚拟区域。指令对进程来说是只读的,数据对进程来说是可读写的。所以这两个区域的权限可以设置成只读和读写,防止程序的指令被有意或者无意篡改。
2.现代 CPU 强大的缓存体系,分开存储可以提高缓存的命中率。
3.当系统中存在多个该程序的副本时,它们的指令都是一样的,所以内存只需要保存一份该程序的指令部分。
simple.c
为了完整了解目标文件中的内容,需要有一个好的目标文件例子。我们以下面的源代码为例,利用 gcc -c simple.c
命令将其编译为目标文件。
1 | // simple.c |
使用 objdump -h simple.o
可以查看 ELF 文件各个段的基本信息,结果如下:
1 | simple.o: 文件格式 elf64-x86-64 |
通过观察输出内容,我们可以得到下面的发现:
- 除了基本的代码段、数据段、BSS 段以外,还有一些其他的段:只读数据段(.rodata)、注释信息段(.comment)、堆栈提示段(.note.GNU-stack)…. 。
- 每个段的第一行是各个属性的值,段的属性有:长度(Size)、虚拟内存区域(VMA)、装载内存区域(LMA)、所在位置(File off)、对齐要求(Algn)
- 每个段的第二行中的 CONTENTS, ALLOC 等表示段的各种属性,例如 CONTENTS 表示该段在文件中存在。
将几个重要的段按照它们在 ELF 结构中的位置绘制出如下的图:
代码段
使用 objdump -s -d simple.o
命令可以将所有段的内容以 16 进制的形式打印出来(-s)、还可以将所有包含指令的段反汇编(-d),代码段部分的内容输出如下:
1 | // 十六进制 1 位相当于二进制 4 位,也就是说 2 位十六机制相当于二进制 8 位 = 1 字节 |
.text
段的第五个字节是:0x55
,也就是 printf 函数的第二条指令:push %rbp
,而最后一个字节是:0xc3
,也就是 main 函数的最后一条指令:retq
。
数据段
1 | Contents of section .data: |
之前说过 .data
存放的是初始化的全局变量和局部静态变量,也就是代码中的 global_init_var
、static int static_init_var
这两个变量。这两个变量都是 4 个字节,加起来 8 个字节,正好对应 54000000
、55000000
。
54000000
对应 global_init_var
变量,代码中是 84,这里似乎有点对不上。这里涉及到了 CPU 的字节序(Byte Order)问题,也就是所谓的大端(Big-endian)和小端(Little-endian)的问题。C 采用的是小端存储,也就是说存储的真实值应该是 00000054,转化为十进制也就是 84。
字节序是什么?
字节存储机制主要有两种:大端(Big-endian)和小端(Little-endian)。我们先来定义两个概念:
1.MSB(Most Significant Bit/Byte):最重要的位或者字节,通常用来表明一个 bit 序列或一个 byte 序列中对整个序列值取值影响最大的那个 bit/byte。
2.LSB(Least Significant Bit/Byte):最不重要的位或者字节,通常用来表明一个 bit 序列或一个 byte 序列中对整个序列值取值影响最小的那个 bit/byte。
例如:0x12345678,0x12 就是 MSB,0x78 就是 LSB
大端(Big-endian)和小端(Little-endian)的区别就是 Big-endian 规定 MSB 在存储时放在低地址,在传输时放在流的开始;LSB 存储时放在高地址,传输时放在流的末尾。
Little-endian 主要应用于现在 PC 的 CPU 中,即 Intel 的 x86 系列兼容机,Big-endian 主要应用于目前的的 Mac 机器中,一般指 PowerPC 系列处理器。
需要注意的是,目前的 TCP/IP 网络和 Java 虚拟机的字节序都是 Big-endian 的。这就意味着如果通过网络传输 0x12345678 这个整型变量,首先被发送的是 0x12、接着是 0x34、然后是 0x56、最后是 0x78。
.rodata 段存放的是只读数据,一般是程序里面的只读变量(如 const 修饰的变量)和字符串常量。单独设置 .rodata 段不光语义上支持了 const 关键字,而且操作系统在加载的时候可以将 .rodata 段映射成只读,可以保证程序的安全性。
BSS 段
1 | 2 .bss 00000004 0000000000000000 0000000000000000 00000108 2**2 |
BSS 段实际上在 ELF 文件中没有内容,它存放的是未初始化的全局变量和局部静态变量,例如上述的 global_uninit_var
和 static_uninit_var
这两个变量。更准确的说是 BSS 段为它们预留了空间。但是我们可以看到 BSS 段的大小只有 4 个字节,这与 global_uninit_var
和 static_uninit_var
的大小 8 个字节不符。
其实在后面的符号表可以看到,只有 static_uninit_var
放入了 BSS 段中,global_uninit_var
没有被放在任何段中,只是一个未定义的 COMMON
符号。这其实跟不同的语言与不同的编译器实现有关,有些编译器会将全局的未初始化变量存放在目标文件 BSS 段,有些则不放,只是预留一个未定义的全局变量符号,等最终链接的时候再在 BSS 段分配空间。
其他段
常用的段名 | 说明 |
---|---|
.rodata1 | Read Only Data,只读数据段,存放字符串常量,全局 const 变量,该段和 .rodata 一样 |
.comment | 存放编译器版本信息,比如 “GCC:(GNU)4.2.0” |
.debug | 调试信息 |
.dynamic | 动态链接信息 |
.hash | 符号哈希表 |
.line | 调试时的行号表,即源代码行号与编译后指令的对应表 |
.note | 额外的编译信息,比如程序的公司名、发布版本等 |
.strtab | String Table 字符串表,用于存储 ELF 文件中用到的各种字符串 |
.symtab | Symbol Table 符号表,从这里可以所以文件中的各个符号 |
.shstrtab | 是各个段的名称表,实际上是由各个段的名字组成的一个字符串数组 |
.plt 和 .got | 动态链接的跳转表和全局入口表 |
.init 和 .fini | 程序初始化和终结代码段 |
ELF 文件结构
通过 simple.c 的结构导致了解了 ELF 文件的轮廓,接着就来看看 ELF 文件的结构,下图是 ELF 目标文件的总体结构,省去了一些复杂的结构。
ELF 文件格式的最前部是 ELF 文件头,就是上文关于 “目标文件的内容” 中提到的文件头。它包含了整个文件的基本属性,比如 ELF 版本、目标机器号、程序入口地址等。紧接着是 ELF 文件各个段。其中与段有关的最重要结构就是段表(Section Header Table),该表描述了 ELF 文件包含的所有段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限以及段的其他属性。
文件头
我们可以使用 readelf -h simple.o
命令查看 simple.o 的文件头,输出结果如下:
1 | ELF 头: |
从上面输出的结果可以看到,ELF 文件头中定义了 ELF 魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI 版本、ELF 重定位类型、硬件平台、硬件平台版本、入口地址、程序入口长度和长度、段表的位置和长度、段的数量等。
ELF 文件有 32 位版本和 64 位版本。它的文件头结构也有这两种,分别叫做 Elf32_Ehdr
和 Elf64_Ehdr
。其实两种版本的 ELF 文件的文件头内容是一样的,只不过有些成员的大小不一样。我们这里以 Elf64_Ehdr
为例描述:
1 | // Elf64_Half: uint16_t,2 字节 |
ELF 魔数
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
- 前四个字节(
7f 45 4c 46
)是所有 ELF 文件都必须相同的标识码。第一个对应 ASCII 字符里面的 DEL 控制符,后面三个刚好是 ELF 这三个字符的 ASCII 字符 - 第五个字节(
02
)用来标识 ELF的文件类别,0 是无效文件,1 是 32 位的,2 是 64 位的 - 第六个字节(
01
)是字节序,0 是无效格式,1 是小端格式,2 是大端格式 - 第七个字节(
01
)是 ELF 文件的主版本号,一般是 1 ,因为 ELF 标准自 1.2 版本后就再也没有更新了 - 第八个字节和第九个字节是 ABI 信息和 ABI 版本信息
- 最后的七个字节 ELF 标准没有定义,一般填 0 ,有些平台会使用这 7 个字节作为扩展标志。
ELF 头文件标识具体可以参考:https://docs.oracle.com/cd/E19253-01/819-7050/chapter6-35342/index.html
文件类型
系统通常通过这个常量来判断 ELF 的真正文件类型,而不是通过文件的扩展名。
- ET_REL:可重定位文件,一般为 .o 文件
- ET_EXEC:可执行文件
- ET_DYN:共享目标文件,一般为 .so 文件
段表
之前我们使用 objdump -h simple.o
查看 ELF 文件的段,这个命令其实只是把 ELF 文件中关键的段显示出来了,而省略了其他辅助性的段,比如:符号表、字符串表等。可以使用命令 readelf -S simple.o
来查看 ELF 文件的段,输出结果如下所示:
1 | Section Headers: |
输出结果就是 ELF 文件段表的内容,段表的结构比较简单,它是一个以 Elf64_Shdr
结构为元素的数组。数组元素的个数等于段的个数,每个 Elf64_Shdr
对应一个段,Elf64_Shdr
又被称为段描述符。ELF 段表第一个元素是无效的段描述符,它的类型是 NULL,除此之外每个段描述符都对应一个段。
1 | typedef struct { |
结构
到此,我们对 simple.o 所有段的位置和长度都给分析清楚了,结构如下:
- 根据上面文件头的信息可以知道段表在文件中的偏移
e_shoff
= 1296,对应的十六进制就是 0x510。 - 根据文件头的信息还可以知道文件头的大小
e_ehsize
= 64,对应的十六进制就是 0x40。 - 其他信息可以参考我们这里的段表信息,例如段表信息中就有
.text
段的偏移值,偏移值正好是文件头的大小 0x40。 Section Table
的大小是 0x380,也就是 896 字节。因为 sizeof(Elf64_Shdr) = 64 字节,896 / 64 = 14,正好对应 14 个段表描述符。(sizeof(Elf32_Shdr) = 40)
段类型
sh_type
段的名字不能真正表示段的类型,对于编译器和链接器来说,主要决定段的属性的是段的类型和段的标志。段的类型相关常量以 SHT_
开头:
- SHT_NULL:0,无效段
- SHT_PROGBITS:1,程序段,包括代码段和数据段
- SHT_STMTAB:2,表示该段内容是符号表
- SHT_STRTAB:3,表示该段内容是字符串表
- SHT_RELA:4,重定位表,该段表示了重定位信息
- SHT_HASH:5,符号表的哈希表
- SHT_DYNAMIC:6,动态链接信息
- SHT_NOTE:7,提示性信息
- SHT_NOBITS:8,表示该段在文件中没内容
- SHT_REL:9,该段包含重定位信息
- SHT_SHLIB:10,保留
- SHT_DNYSYM:动态链接的符号表
段标志位
sh_flag
段的标志位表示该段在进程虚拟地址空间的属性,比如是否可写,是否可执行等。相关常量以 SHF_
开头:
- SHF_WRITE:1,表示该段在进程空间中可写
- SHF_ALLOC:2,表示该段在进程空间中需要分配空间
- SHF_EXECINSTR:4,表示该段在进程空间中可以被执行,一般指代码段
段链接信息
sh_link、sh_info
如果段的类型是与链接相关(不论动态链接还是静态链接)的,比如重定位表、符号表等,那么 sh_link、sh_info 的意义如下:
- SHT_DYNAMIC:sh_link = 该段所使用的字符串表在段表中的下标,sh_info = 0
- SHT_HASH:sh_link = 该段所使用的符号表在段表中的下标,sh_info = 0
- SHT_RELA、SHT_REL:sh_link = 该段所使用的相应符号表在段表中的下标,sh_info = 该重定位表所作用的段在段表中的下标
- SHT_SYMTAB、SHT_DYNSYM:sh_link = 操作系统相关的,sh_info = 操作系统相关的
- other:sh_link = SHN_UNDEF,sh_info = 0
重定位表
我们注意到,simple.o 中有一个叫做 .rela.text
的段,它的类型告诉我们它是一个重定位表。正如我们之前所说的,链接器在处理目标文件时,必须要对目标文件中某些部位进行重定位,即代码段和数据段中哪些对绝对地址的引用的位置。哪些重定位的信息都记录在 ELF 文件的重定位表里面,对于每个需要重定位的代码段和数据段,都会有一个相应的重定位表。
- 比如我们这里的
.rela.text
就是针对.text
段的重定位表,.rela.eh_frame
就是针对.eh_frame
的重定位表。 - 观察一下这里的
.rela.text
段,它的sh_link
= 11,正好是符号表在段表中的索引下标,它的link_info
= 1,正好是它所作用的段.text
在段表中的索引下标。
这里有一个不好的地方,simple.c 源代码中 printf 已经定义了:
int printf(const char * format, ...) { }
程序员的自我修养中不是这样的,而是只有定义:int printf(const char * format, ...)
因为它的无法运行,我就自己修改了。我大意了啊,大佬是故意这么写的啊。
所以,我这里有点不好解释为啥
.text
段要重定位,大佬那边因为 printf 函数只声明,定义可能在其他地方,所以它的.text
段一定需要重定位。我们这里不好解释,有点复杂,不过我们可以使用objdump -r simple.o
命令查看重定位表里面的内容,勉强给自己一个安慰。
字符串表
ELF文件中用到了很多字符串,比如段名、变量名,因为字符串的长度往往是不定的,所以用固定的机构来表示比较困难。一种常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。
一般字符串表在 ELF 文件中也以段的形式保存,常见的段名为 .strtab
或 .shstrtab
,这两个字符串表分别为字符串表和段表字符串表。顾名思义,字符串表用来保存普通的字符串,比如符号的名字,段表字符串表用来保存段表中用到的字符串,最常见的就是段名。
链接的接口-符号
链接过程的本质就是要把多个不同的目标文件之间相互粘到一起。在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对变量和函数的地址的引用。在链接中,我们将变量和函数统称为符号,变量名或者函数名就是符号名。
每一个目标文件都会有一个相应的符号表,这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值,对于变量和函数来说,符号值就是它们的地址。
除了变量和函数以外,还存在其他几种不常用到的符号。我们将符号表中所有的符号进行分类,它们可能是下面这些类型中的一种:
- 定义在本目标文件的全局符号,可以被其他目标文件引用,比如 simple.c 中的
global_init_var
、func
、main
- 在本目标文件中引用的全局符号,却没有定义在本目标文件,这一般叫做外部符号,也就是前面所说的符号引用,比如 simple.c 中去掉 { } 的
printf
- 段名,这种符号往往是编译器产生,它的值就是该段的起始地址,比如 simple.c 中的
.text
,.data
- 局部符号,这种符号往往由编译器产生,比如 simple.c 里面的
static_init_var
和static_uninit_var
。调试器可以使用这些符号来分析程序或崩溃时的核心转储文件。这些局部符号对于链接过程没有作用,链接器往往也忽略它们。 - 行号信息,即目标文件与源代码中代码行的对应关系,也是可选的。
对于我们来说,最关心的就是全局符号,也就是上面的第一类和第二类,其他都是次要的。
我们可以使用命令 nm simple.o
查看符号表中的内容:
1 | 000000000000005d T func |
符号表的结构
ELF 文件中的符号表往往是文件中的一个段,段名一般叫做 .symtab
。符号表的结构很简单,它是一个 Elf64_Sym
结构的数组,每个 Elf64_Sym
结构对应着一个符号。这个数组的第一个元素,也就是下标为 0 的元素为无效的 未定义 符号。
1 | typedef struct { |
符号绑定信息和类型
st_info
命名只有一个字节,也就是只有 8 位,咋能有低 4 位和高 28 位的呢?看了下计算方式:Oracle Solaris 11.1 链接程序和库指南 ,和位运算有关,应该是我对位运算的理解有误。
符号绑定信息:
- STB_LOCAL:0,局部符号,对于目标文件的外部不可见
- STB_GLOBAL:1,全局符号,外部可见
- STB_WEAK:2,弱引用
符号类型:
- STT_NOTYPE:0,未知符号类型
- STT_OBJECT:1,该符号是个数据对象,比如变量、数组
- STT_FUNC:2,该符号是个函数或者其他可执行代码
- STT_SECTION:3,该符号表示一个段,这种该符号必须是 STB_LOCAL
- STT_FILE:4,该符号表示文件名,一般是目标文件对应的源文件名,它一定是 STB_LOCAL,且
st_shndx
一定是 SHN_ABS
符号所在的段
如果符号定义在目标文件中,那么这个成员表示符号所在的段在段表中的下标;但如果符号不是定义在本目标文件中,或者对于有些特殊符号,st_shndx
的值有些特殊,如下所示:
- SHN_ABS:0xfff1,该符号包括了一个绝对的值,比如文件名的符号
- SNH_COMMON:0xfff2,表示该符号是一个 COMMON 块类型的符号,一般来说未初始化的全局符号就是这种类型的,例如 simple.c 中的 global_uninit_var
- SHN_UNDEF:0,表示该符号未定义,该符号在本目标文件中被引用到,但是定义在其他目标文件中。
符号值
每个符号都有一个对应的值,如果这个符号是一个变量或者函数的定义,那么这个符号的值就是这个变量或者函数的地址。更准确的说是以下几种情况:
- 在目标文件中,如果是符号的定义并且该符号不是 COMMON 块,则 st_value 表示该符号在段中的偏移。即符号所对应的变量或者函数位于由 st_shndx 指定的段,偏移 st_value 的位置。这也是目标文件中定义全局变量的符号的最常见情况,比如 simple.c 中的
printf
,func
,main
,global_init_var
- 在目标文件中,如果符号是 COMMON 块,则 st_value 表示该符号的对齐属性,比如 simple.c 中的 global_uninit_var
- 在可执行文件中,st_value 表示符号的虚拟地址,这个地址对于动态链接器来说十分有用
根据上面的介绍,我们对 ELF 文件的符号表有了大致的了解,接着以 simple.o 里面的符号为例,分析各个符号在符号表中的状态。可以使用 readelf -s simple.o
命令查看,输出结果如下:
1 | Symbol table '.symtab' contains 17 entries: |
Num:数组下标;Value:符号值,st_value;Size:符号大小,st_size;
Type、Bind:符号类型和绑定信息,st_info
Vis:目前在 C/C++ 中暂未使用
Ndx:该符号所属的段,st_shndx;Name:符号名称
printf
、func
、main
函数都是定义在 simple.c 里面的,它们所在的位置都是代码段,所以 Ndx 为 1。它们是函数,所以类型是STT_FUNC
。它们是全局可见的,所以是STB_GLOBAL
。Size 表示函数指令所占的字节,Value 表示函数相对于代码起始位置的偏移量- 如果
printf
去掉 { },那它就只是在 simple.c 里面被引用,但是没有被定义。所以它的 Ndx 是 SHN_UNDEF global_init_var
是已初始化的全局变量,它被定义在 .data 段,所以 Ndx 为 3。global_uninit_var
是未初始化的全局变量,它是一个 SNH_COMMON 类型的符号,本身并没有存在于 BSS 段中static_init_var.1920
和static_uninit_var.1921
是两个静态变量,它们的绑定属性是STB_LOCAL
,即只是编译单元内可见。为什么它们有 .1920 和 .1921 的后缀,是因为符号修饰的问题- 对于哪些
STB_SECTION
类型的符号,它们表示下标为 Ndx 的段的段名 - simple.c 这个符号表示编译单元的源文件名
符号修饰和函数签名
C++ 拥有类、继承、虚机制、重载、名称空间等特性,它们使得符号管理更加复杂。为了支持 C++ 这些复杂的特性,人们发明了符号修饰。
1 | int func(int); |
上面的代码有 6 个名叫 func
的同名函数,只不过它们的返回类型,参数,所在的名称空间不同。我们引入一个术语叫做函数签名,函数签名包含了一个函数的信息,包括函数名、参数类型、所在类和名称空间以及其他信息。
在编译器以及链接器处理符号的时候,它们使用某种名称修饰的方法,使得每种函数签名对应一个修饰后名称。具体的修饰规则就不说了,只需要知道 C++ 源代码编译后的目标文件中所使用的符号名是相应的函数和变量修饰后的名称。
签名和符号修饰不光用在函数上, C++ 的全局变量和静态变量也有同样的机制。对于全局变量来说,它跟函数一样都是一个全局可见的名称,它也遵循上面的名称修饰机制。静态变量也是类似的符号修饰机制。
extern “C”
C++ 为了与 C 兼容,在符号的管理上,C++ 有一个用来声明或定义一个 C 的符号的 extern "C"
关键字用法
1 | extern "C" { |
C++ 编译器会在 extern "C"
大括号内部的代码当做 C 语言代码处理
弱符号和强符号
对于 C/C++ 来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局符号为弱符号。也可以通过 GCC 的 _attribute_((weak))
来定义任何一个强符号为弱符号。注意:强符号和弱符号否是针对定义来说的,不是针对符号引用。
例如下面的例子:weak
和 weak2
都是弱符号,strong
和 main
都是强符号。而 ext
既非强符号也非弱符号,因为它是一个外部变量的引用。
1 | extern int ext; |
针对强弱符号,链接器会按照如下的规则处理:
- 规则 1:不允许强符号被多次定义(也就是不同的目标文件不能有同名的强符号),否则就会报错
- 规则 2:如果一个符号在某个目标文件中是强符号,在其他文件中都弱符号,那么选择强符号
- 规则 3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个
目前我们所看到的对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它必须要正确的决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用。
与之对应的还有一种弱引用,在处理弱引用时,如果该符号有定义,则该链接器将该符号的引用决议,如果没被定义,则链接器对该引用不报错。一般对于未定义的弱引用,链接器默认其为 0,或者一个特殊的值,以便程序代码能被识别。
弱引用和弱符号主要用于库的链接过程,弱符号跟链接器的 COMMON
块概念联系很紧密。
静态链接
为了深入了解链接的核心内容:静态链接,我们给出下面的例子:
1 | // a.c |
我们先使用命令 gcc -c a.c b.c
将 a.c 和 b.c 分别编译成目标文件 a.o 和 b.o。从代码中可以看到 b.c 中一共定义了两个全局符号:一个是变量 shared
,另一个是函数 swap
。a.c 里面定义了一个全局符号:函数 main
,并且它引用了 b.c 中的 shared
、swap
符号。接下来就是把 a.o 和 b.o 这两个目标文件链接在一起并最终形成一个可执行文件 ab。
这里存在一个问题,使用这个命令编译出来的目标文件,在接下来的链接过程中会出错。具体可以参考这里:gcc编译怎么解决undefined reference to __stack_chk_fail?,正确的命令是:
gcc -c a.c b.c -fno-stack-protector
空间地址分配
a.o 和 b.o 要合并在一起,首先要考虑的就是:对于多个输入目标文件,链接器如何将他们的各个段合并到输出文件?或者说输出文件的空间如何分配给输入文件?
按序叠加
最简单的方式就是将输入的目标文件按照次序叠加起来。但是这样会造成一个问题,当有很多个输入文件的情况下,输出文件将会有很多零散的段。这种做法非常浪费空间,因为每个段都需要有一定的地址和空间对齐要求。
相似段合并
一个更实际的做法是将相同性质的段合并到一起。
链接器为目标文件分配地址和空间,这里的地址和空间的含义是什么?
这里的含义有两种,第一个是输出的可执行文件的空间,第二个是在装载后的虚拟地址中的虚拟地址空间。
对于有实际数据的段,比如:.text
、.data
来说,它们在文件中和虚拟地址中都要分配空间,因为它们在两者中都存在。
对于.bss
这样的段来说,分配空间的意义只限于虚拟地址空间,因为它们在文件中没有内容。
事实上,我们谈到的空间分配只关注虚拟地址空间的分配,因为这个关系到链接器后面关于地址计算的步骤。
现在的链接器空间分配上的策略基本上都采用相似段合并,使用这种方法的链接器都采用一种叫两步链接的方法。也就是说整个链接过程分两步:
- 空间与地址分配:扫描所有的输入目标文件,获得它们的各个段长度、属性和位置,并且将输入目标文件中的所有符号定义和符号引用收集起来,统一放到一个全局符号表中。这一步中,链接器能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。
- 符号解析与重定位:使用上一步收集到的所有信息,读取输入文件中段的数据、重定位信息、并且进行符号解析与重定位、调整代码中的地址等。链接的核心就是这一步,特别是重定位过程。
使用 ld 链接器将 a.o 和 b.o 链接起来:ld a.o b.o -e main -o ab
,-e main 是将 main 设置为程序入口,因为 ld 链接器默认的程序入口是: _start
。使用 objdump -h
命令查看链接前后的地址分配情况:
1 | // objdump -h a.o |
VMA 表示虚拟地址,LMA 表示加载地址,正常情况下这两个值应该是一样的。但是在有些嵌入式系统中,VMA 和 LMA 是不同的,这里我们只要关注 VMA 即可。
在链接之前,目标文件中的所有段的 VMA 都是 0,因为虚拟空间还没有被分配,所以它们都默认为 0。等到链接之后,可执行文件 ab 中的各个段都被分配到了相应的虚拟地址。
为啥 .text
段分配到 0x00000000004001c8
,而不是从虚拟地址的 0 地址开始分配呢?这涉及操作系统的进程虚拟地址空间的分配规则:
在 32 位 Linux 下, ELF 可执行文件默认从地址
0x08048000
开始分配。在 64 位 Linux 下,ELF 可执行文件默认从地址
0x00400000
开始分配。
原因参考:在linux下,为什么 i386 ELF可执行文件默认从地址(.text)0x08048000开始分配。 而 x64是0x400000
符号地址的确定
经过前面的相似段合并,这个时候输入文件中的各个段在链接后的虚拟地址就已经确定了,接下来就是开始计算各个符号的虚拟地址。
因为各个符号在段内的相对位置是固定的,所以这时候其实 main
、shared
、swap
的地址已经是确定的了,只不过链接器需要给每一个符号加上偏移量,使它们能调整到正确的虚拟的位置。比如 a.o 中的 main
符号相对于 a.o 的偏移是 X,经过链接后 .text
段位于虚拟地址 0x00401000
,那么 main
的地址就是 0x00401000 + X
。
符号解析与重定位
重定位
在完成空间和地址的分配之后,链接器就进入了符号解析和重定位的步骤,这也是静态链接的核心内容。
我们先来看看 a.o 文件在链接前是怎么使用 shared
和 swap
这两个其他模块的符号,使用 objdump -d a.o
命令查看 a.o 文件代码段反编译后的结果,内容如下:
1 | 0000000000000000 <main>: |
已经在图中标出了 shared
和 swap
的调用位置,后面重定位表中会解释为什么在这里。由于链接前不知道 shared
和 swap
的符号地址,所以这里使用 0x00000000
来代替
接下来我们使用命令 objdump -d ab
查看链接后的结果,如下所示。发现此时 shared 和 swap 的位置已经被换成了有意义的值。
1 | 0000000000401000 <main>: |
- 先说这里的
0xe22f0000
,由于存储是小端序,所以其实是0x00002fe2
。lea 指令类似于近地址相对位移调用指令,指令后面的四个字节就是被调用的 变量/函数 相对于调用指令下一条指令的偏移量。所以可以得出shared
符号的地址 = 下一个指令地址(0x40101e)+ 偏移量(0x00002fe2)=0x404000
- callq 指令也类似于近地址相对位移调用指令,因此
swap
符号的地址 = 下一个指令地址(0x40102b)+ 偏移量(0x00000007)=0x401032
重定位表
那么链接器是怎么知道哪些指令是需要被调整的呢?这些指令的哪些部分需要被调整呢?怎么调整呢?这些内容都可以在重定位表中找到答案。
对于每个需要被重定位的 ELF 段来说,都有一个对应的重定位表,这个重定位表往往就是 ELF 文件中的一个段。比如这里 a.o 目标文件的 .text
段需要被重定位,就会有一个叫做 .rela.text
的段保存了 .text
段需要重定位的信息。可以使用 readelf -S a.o
查看 a.o 的详细段信息,结果如下:
可以使用命令 objdump -r a.o
查看目标文件的重定位表,内容如下。这个命令可以查看 a.o 目标文件中所有需要重定位的地方,每个要被重定位的地方叫一个重定位入口。
1 | a.o: 文件格式 elf64-x86-64 |
OFFSET
:表示该重定位入口在要被重定位的段中的偏移位置,这里的 0x1a 和 0x27 就是用来在.text
段中找到shared
和swap
符号调用位置TYPE
:重定位入口的类型,指明修正指令地址格式的方式
关于 R_X86_64_PC32 和 R_X86_64_PLT32 对应的指令修改方式可以参考:
重定位表对应的数据结构如下:
1 | typeder { |
符号解析
我们在上面说到 shared
符号的地址是: 0x404000
,swap
的符号地址是:0x401032
。我们可以查看链接后的符号地址,发现确实如此。
1 | // readelf -s a.o |
COMMON 块
现在的编译器都支持一种叫 COMMON
块的机制,这个机制最早来源于 Fortran。早期的 Fortran 没有动态分配空间的机制,程序员必须实现声明它所需要的临时使用空间大小,Fortran 把这种空间叫 Common 块,当不同的目标文件需要的 Common 块空间大小不一致的时,以最大的为准。
现代的链接机制在处理弱符号的时候,采用的就是和 COMMON
块一样的机制。前面的 simple.c 例子中可以看到,编译器将为初始化的全局变量定义作为弱符号处理,比如弱符号 global_uninit_var
。
如果在另外一个目标文件中也定义了 global_uninit_var
,且未初始化,它的类型为 double。情况会怎么样呢?按照 Common 类型的链接规则,global_uninit_va
r 的大小以输入文件中最大的那个为准,所以,global_uninit_var
所占的空间将会是 8 个字节。
当然,COMMON
块的链接规则是针对符号都是弱符号的情况,如果其中一个符号为强符号,那么最终输出结果中的符号所占空间与强符号相同。
这里就可以回答为什么不直接把未初始化的全局变量也当作未初始化的静态变量一起处理,为它在 BSS 段中分配空间而是将其标记为一个
COMMON
块
这是因为编译器在将一个编译单元编译成目标文件的时候,如果该单元包含了弱符号,那么该符号最终所占空间的大小是未知的,因为其他编译单元该符号所占空间比本单元该符号所占的空间要更大。所以编译器无法为该弱符号在
BSS
段分配空间。但是在链接过程中可以确定弱符号的大小,因此当链接器读取所有输入目标文件后,任何一个弱符号的最终大小都是可以确定的,所以可以在最终输出文件的BSS
段为其分配空间。
C++ 相关问题
全局构造与析构
在 main
函数被调用,为了程序能够顺利执行,要先初始化线程执行环境,比如堆分配初始化(malloc、free)、线程子系统等。C++ 的全局对象构造函数也是在这一时期被执行的。
对于某些场合,程序的一些特定操作必须在 main
函数之前被执行,还有一些操作必须在 main
函数之后被执行,很具有代表性的就是 C++ 的全局对象构造和析构函数。因此 ELF 还定义了两种特殊的段:
.init
:该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在main
函数被调用之前, GLibc 的初始化部分安排执行这个段中的代码。.fini
:该段保存着进程终止代码指令,因此,当一个程序的main
函数正常退出时, GLibc 会安排执行这个段中的代码。
C++ 与 ABI
我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为 ABI
(Application Binary Interface)。API
往往指源代码级别的接口,ABI
是指二进制层面的接口。
静态库链接
程序如何使用操作系统提供的 API?一般情况下,一种语言的开发环境往往会附带有语言库,这些库就是对操作系统 API 的封装。
其实静态库可以简单看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。比如我们在 Linux 中经常用到的 C 语言静态库 libc 位于 usr/lib/libc.a
为什么静态库里面一个目标文件只包含一个函数,例如 libc.a 的 printf.o 只有 printf 函数?
我们知道,链接器在链接静态库的时候是以目标文件为单位的。比如我们引用了静态库的 printf 函数,那么链接器就会把库中包含 printf 函数的那个目标文件链接进来。如何很多函数都放在一个目标文件中,很可能很多没用的函数都被链接到了输出结果中。
装载
可执行文件只有装载到内存以后才能被 CPU 执行。下面将介绍 ELF 文件在 Linux 下的装载过程:
- 什么是进程的虚拟地址空间
- 装载的方式
- 进程虚拟空间分布情况
进程虚拟地址空间
程序和进程的区别?
- 程序(狭义上可以说是可执行文件 )是一个静态概念,它就是一些预编译好的指令和数据集合的一个文件。
- 进程是一个动态的概念,它是程序运行时的一个过程。
每个程序运行起来后,它将拥有自己独立的虚拟地址空间(Virtual Address Space)。这个虚拟空间的地址大小由计算机的硬件平台决定,具体低说是由 CPU 的硬件平台决定的。硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小。
比如 32 位的硬件平台决定了虚拟地址空间的地址为 0 ~ 232-1,即 0x00000000 ~ 0xFFFFFFFF
,也就是我们常说的 4 GB 虚拟空间大小;而 64 的硬件平台具有 64 位寻址能力,它的虚拟地址空间达到了 264 字节,即 0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF
,总共 17179869184 GB。
32 位和 64 位的区别:
这里的 32 和 64 主要说的是 CPU 一次处理数据的能力。32 就是 32 位,即一次可以处理 4 个字节的数据。64 就是 64 位,即一次可以处理 8 个字节的数据。32 位机器可以寻址 2^32 ,64 位机器可以寻址 2 ^ 64。
一般来说 C 语言的指针大小的位数和虚拟空间的位数相同,如 32 位平台下的指针为 32 位,即 4 字节;64 位平台下的指针为 64 位,即 8 字节。
32 位的 CPU 下,程序使用的空间能不能超过 4 GB?需要从两个角度回答,首先如果空间指的是虚拟地址控件,答案是不能。如果空间指的是计算机的内存空间,答案是可以的。
装载的方式
最简单的装载方式就是将程序所需要的指令和数据全部装入内存中,但是这对内存的消耗是巨大的。研究发现,程序运行是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不常用的数据存放在磁盘中,这就是动态装入的基本原理。
覆盖装入和页映射是两种很典型的动态装载方法。
覆盖装入
覆盖装入的方法把挖掘内存的潜力交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干份,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉,这个小的辅助代码就是所谓的覆盖管理器。
页映射
页映射是虚拟存储机制的一部分,它随着虚拟存储的发明而诞生。和覆盖装入一样,页映射也不是一下子就把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照页为单位划分成若干个页,以后所有装载和操作的单位都是页。常见的页大小有 4096 字节、8192 字节、2 MB、4 MB 等。
更换页面的时候有很多算法可以选择,例如:先进先出 - FIFO,最近最少使用算法 - LRU。这种装载方式就是现代的操作系统,更加准确的说是 操作系统的存储管理器。
操作系统角度看待装载
进程的建立
从操作系统角度看,一个进程最关键的特征是它拥有独立的虚拟地址空间。很多时候一个程序被执行的同时都伴随一个新的进程的创建,那么就看一下最通常的情况:创建一个进程,然后装载相应的可执行文件并且执行。上述过程需要做以下三件事:
- 创建一个独立的虚拟地址空间
- 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
- 将 CPU 的指令寄存器设置成可执行文件的入口地址,启动运行
创建虚拟地址空间:我们知道一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的数据机构。在 i386 的 Linux 下,创建虚拟地址空间实际上只是分配一个页目录,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。
读取可执行文件头,建立映射关系:第一步页映射关系函数是虚拟地址空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。考虑最简单的情况,假设我们的 ELF 可执行文件只有一个段 .text
,它的虚拟地址是 0x08048000
,它在文件中的大小为 0x000e1
,对齐为 0x1000
。操作系统创建进程后,会在相应的数据结构中设置有一个 .text
段的 VMA:它在虚拟空间中的地址为 0x08048000 ~ 0x08049000
,它对应 ELF 文件中偏移为 0 的 .text
。
Linux 会将进程虚拟空间中的一个段叫做虚拟内存区域 - VMA,Virtual Memory Area
将 CPU 的指令寄存器…,启动运行:操作系统通过设置 CPU 的指令寄存器将控制权转交给进程,由此进程开始执行。这一步可以简单的理解为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。(ELF 文件头保存有入口地址)
页错误
上面的步骤执行完后,可执行文件的真正指令和数据都没有被装入内存中。当程序开始执行的时候,会发现要执行的代码所在页面是一个空页面,这就会被认为是一个页错误。CPU 将控制权转交给操作系统,操作系统有专门的页错误处理进程来处理这种情况。
这个时候,我们前面提到的装载过程第二步建立的数据结构起到了关键的作用,操作系统将查询这个数据机构,然后找到空白页面所在的 VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建议映射关系,然后把控制权转回给进程,进程从刚才页错误的位置重新开始执行。
进程虚拟空间分布
链接视图和执行视图
上面说到的例子只有一个代码段,实际情况往往比这个复杂很多。当段的数量增多时,就会产生空间浪费。因为我们知道,ELF 文件被映射时,是以系统的页长度为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;如果不是,那么多余的一部分也将占用一个页。一个 ELF 文件往往有十几个段,内存空间的浪费可想而知。
站在装载的角度看问题时,可以发现装载实际上不关心可执行文件各个段所包含的实际内容,只关心一些和装载相关的内容,最主要的就是段的权限(可读、可写、可执行)。
ELF 文件中,段的权限往往只有为数不多的几种组合,基本上是三种:
- 以代码段为代表的权限为可读可执行的段
- 以数据段和 BSS 段为代表的权限为可读可写的段
- 以只读数据段为代表的权限为只读的段
那么我们可以找到一个简单的方案:对于相同权限的段,把它们合并到一起当做一个段进行映射。
比如有两个段分别叫 .text
和 .init
,它们包含的分别是程序的可执行代码和初始化代码,并且它们权限相同,都是可读可执行的。假设 .text
为 4097 字节,.init
为 512 字节,这两个段分别映射的话就需要占用三个页,但是如果将它们合并到一起映射的话只需占用两个页面,如下图所示:
ELF 可执行文件引入了一个概念叫做 Segment,一个 Segment 包含一个或者多个属性类似的 Section。正如上面的例子,.init
和 .text
合并在一起看做一个 Segment,那么装载的时候就可以把它们看做一个整体一起映射,也就是说映射之后进程虚拟空间中只有一个相对应的 VMA,而不是两个。这样做的好处是很明显的减少页面内部碎片,从而节省内部空间。
我们很难将 Segment 和 Section 做翻译上加以区分,因为很多时候两者翻译往往相同
Segment 实际上是从装载的角度重新划分了 ELF 的各个段。在目标文件链接成可执行文件的时候,链接器会尽量把相同权限的段分配在同一空间。在 ELF 中把这些属性相似的又连在一起的段叫做一个 Segment,而系统正是按照 Segment 而不是 Section 来映射可执行文件的。
下面是一个很小的程序,源代码如下:
1 |
|
我们使用静态链接的方式(gcc -static section.c -o section.elf
)将其编译链接成可执行文件,然后得到: section.elf 文件
1.使用命令 readelf -S section.elf
查看 section.elf 可执行文件,发现共有 32 个段(Section):
1 | readelf -S section.elf |
2.使用命令 readelf -l section.elf
查看 section.elf 文件的 Segment。正如描述 Section 属性的结构叫做段表,描述 Segment 的结构叫程序头(Program Header),它描述了 ELF 文件该如何被操作系统映射到进程的虚拟空间。
1 | Elf file type is EXEC (Executable file) |
可以看到,这个可执行文件中共有 10 个段。从装载的角度看,目前只关心前四个 LOAD
类型的 Segment,因为只有它是需要被映射的。其他的诸如 NOTE、TLS、GNU_STACK、GNU_RELRO 都是装载时起辅助作用的。
可以用下图来表示 section.elf 可执行文件的段与进程虚拟空间的映射关系:
总的来说,Segment 和 Section 是从不同的角度来划分同一个 ELF 文件,这个在 ELF 文件中被称为不同的视图,从 Section 的角度来看就是链接视图,从 Segment 的角度来看就是执行视图。当说到 ELF 装载的时候,段指的是 Segment,而其他情况下,段指的是 Section。
程序头表
ELF 可执行文件有一个专门的数据结构叫做程序头表(Program Header Table)用来保存 Segment 的信息。因为 ELF 文件不需要被装载,所以它没有程序头表,而 ELF 的可执行文件和共享库文件都有,和段表一样,程序头表也是一个结构体数组,它的结构如下:
1 | typedef struct { |
对于 LOAD 类型的 Segment 来说,p_memsz
的值不可以小于 p_filesz
,否则就是不符合常理的。但是,如果大于有是什么意思呢?如果 p_memsz
大于 p_filesz
,就表示该 Segment 在内存中所分配的空间大小超过文件中实际的大小,这部分多余的内容全部填充 0。
这样构造的好处是,我们在构造 ELF 可执行文件时不需要再额外设置 BSS
的 Segment 了,可以把数据 Segment 的 p_memsz
扩大,额外的部分就是 BSS
。因为数据段和 BSS 段唯一的区别就是:数据段从文件中初始化内容,而 BSS
段的内容全部初始化为 0。
堆和栈
操作系统中, VMA 除了被用来映射可执行文件中的各个 Segment 以外,它还有其他的作用。操作系统通过使用 VMA 来对进程的地址空间进行管理,进程在执行的时候还需要栈(Stack)、堆(Heap)等空间,它们在进程中的表现也是以 VMA 的形式存在的。可以使用 /proc
命令查看进程虚拟空间分布:
1 | // ./section.elf & |
第一列例如 00400000-00401000 表示的是 VMA 的地址范围
第二列是 VMA 的权限,r 表示可读、w 表示可写、x 表示可执行、p 表示私有、s 表示共享
第三列表示偏移,表示 VMA 对应的 Segment 在映像文件中的偏移
第四列表示映像文件所在设备的主设备号和次设备号
第五列表示映像文件的节点号
最后一列是映像文件的路径
我们可以看到进程中有 11 个 VMA,只有前 5 个是映射到可执行文件中的 5 个 Segment。另外 6 个所在设备的主设备号和次设备号都是 0,则表示它们没有映射到文件中,这种 VMA 叫做匿名虚拟内存区域。
我们可以看到有两个区域分别是 堆(Heap
)和 堆(Stack
),这两个 VMA 几乎在所有的进程中都存在,C 语言里面常用的 malloc 是从堆中分配的,堆由系统管理。栈一般叫做堆栈,每个线程都有属于自己的堆栈。
还有一个很特殊的 VMA 叫做 vsdo
,它的地址位于内核空间,事实上它是一个内核模块,进程可以通过访问这个 VMA 来跟内核进行一些通信。
通过上面的例子,可以总结一下虚拟地址空间的概念:操作系统通过给进程空间划分出一个个 VMA 来管理进程的虚拟空间:基本原则是将相同权限属性的、有相同映像文件的映射成一个 VMA,一个进程基本可以划分为如下几种 VMA 区域:
- 代码 VMA:权限只读、可执行;有映像文件
- 数据 VMA:权限可读写、可执行;有映像文件
- 堆 VMA:权限可读写、可执行;无映像文件,匿名,可向上扩展
- 栈 VMA:权限可读写、不可执行;无映像文件,匿名,可向下扩展
通过 /poc 看到的 VMA 地址范围可能和预测的不太一样,这是因为 Linux 在装载 ELF 文件的时候实现了一种 Hack 的做法….
段地址对齐
可执行文件最终是要被操作系统装载运行的,装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位。
对于 Intel 80x86 系列处理器来说,默认的页大小是 4096 字节,4 KB(4096 = 2^12 = 16^3 = 0x1000)。也就是,我们要将一段物理内存和进程虚拟空间之间建立映射关系,这段内存的空间长度必须是 4096 的整数倍,并且这段空间在物理内存和进程虚拟地址空间中的起始地址必须是 4096 的整数倍。
由于这些限制,对于可执行文件来说,它需要尽量优化自己的空间和地址安排,以节省空间。以下面的例子来看:假设有一个 ELF 可执行文件,它有 3 个 Segment 需要装载,我们将其命名为 SEG0,SEG1 和 SEG2,每个段的长度和偏移如下:
段 | 长度 | 偏移 | 权限 |
---|---|---|---|
SEG0 | 127 | 34 | 可读可执行 |
SEG1 | 9899 | 164 | 可读可写 |
SEG2 | 1988 | 只读 |
这是很常见的一种情况,每个段的长度都不是页长度的整数倍,一只最简单的映射方式是每个段分开映射,对于长度不足一个页的部分占一个页。 32 位机器的可执行文件的起始虚拟地址是 0x08048000,那么映射后各个段的虚拟地址和长度如下:
段 | 起始虚拟地址 | 大小 | 有效字节 | 偏移 | 权限 |
---|---|---|---|---|---|
SEG0 | 0x08048000 | 0x1000 | 127 | 34 | 可读可执行 |
SEG1 | 0x08049000 | 0x3000 | 9899 | 164 | 可读可写 |
SEG2 | 0x0804C000 | 0x1000 | 1988 | 只读 |
可以看到这种对齐方式在文件段的内部会有很多内部碎片,浪费棋盘空间。整个可执行文件的三个段的总长度只有 12014 字节,却占据了 5 个页,即 20480 字节,空间利用率只有 58.6%。
为了解决这个问题,有些 Unix 采系统采用了一个很取巧的办法,就是让那些各个段接壤部分共享一个物理页面,然后将这些物理页面分别映射两次。下面分别是可执行文件段未合并和段合并的情况:
从某种角度看,好像是整个 ELF 文件从文件最开始到某个点结束,被逻辑上分成了以 4096 字节为单位的若干个块,每个块都被装载到物理内存中,对于哪些位于两个中间的块,它们将会被映射两次。
进程栈初始化
进程刚启动的时候,需知道一些进程运行的环境,最基本的就是系统环境变量和运行参数,很常见的一种做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间栈中。让我们来看看 Linux 进程初始化后栈的结构,我们假设系统有两个环境变量:
1 | HOME=/home/user |
运行程序的命令是 ./程序 prog 123
,并且我们假设堆栈底部地址为 0xBF802000
,那么进程初始化后的堆栈如下图所示:
栈顶寄存器 esp 指向的位置是初始化以后堆栈的顶部,最前面的四个字节表示命令行参数的数量,我们的例子里面是两个,即 prog
和 123
,紧接着就是分别指向这两个参数字符串的指针;后面跟了一个 0;接着是两个指向环境变量字符串的指针,它们分别指向 HOME=/home/user
和 PATH=/usr/bin
;后面紧跟一个 0 表示结束。
进程在启动以后,程序的库部分会把堆栈里面的初始化信息中的参数信息传递给 main
函数,也就是我们熟知的两个 argc
和 argv
参数,这两个参数分别对应命令行参数数量和命令行参数字符串数组。
1 | int main(int argc, char* argv[]) |
Linux 内核装载 ELF 过程简介
当我们在 Linux 下输入一个命令执行某个 ELF 文件,Linux 操作系统是怎样装载这个 ELF 文件并且执行它的呢?
首先在用户层面,bash 进程
会调用 fork()
系统调用创建一个新的进程,然后新的进程调用 execve()
系统调用执行指定的 ELF 文件,原先的 bash 进程
继续返回等待才启动的新进程结束,然后继续等待用户输入命令。
execve()
系统调用 的原型如下:
1 | // fileName:程序文件名 |
在进入 execve()
系统调用后,Linux 内核就开始进行真正的装载工作。execve()
系统调用相应的入口是 sys_execve()
,sys_execve()
在进行一系列的参数检查复制后,调用 do_execve()
。
do_execve()
首先会查找被执行的文件,如果找到文件则读取文件的前 128 个字节,然后通过 search_binary_handle()
去搜索匹配合适的可执行文件装载处理过程。
search_binary_handle()
会通过判断文件头部的魔数确定文件格式,并且调用相应的装载处理过程。比如 ELF 文件的装载处理过程叫做 load_elf_binary()
,a.out 文件的装载处理过程叫做 load_aout_binary()
。
这里只关心 ELF 文件的装载处理过程,它的主要步骤是:
- 检查 ELF 可执行文件格式的有效性,比如魔数,程序头表中段的数量
- 寻找动态链接的 .interp 段,设置动态链接路径
- 根据 ELF 可执行文件的程序头表的描述,对 ELF 文件进行映射,比如代码、数据、只读数据
- 初始化 ELF 进程环境
- 将系统调用的返回地址修改成 ELF 可执行文件的入口点,如果是静态链接,这个入口点就是文件头中 e_entry 所指的地址,如果是动态链接,程序入口点就是动态链接器
当 load_elf_binary()
执行完毕,返回至 do_execve()
再返回至 sys_execve()
时,上面的第五步中已经把系统调用的返回地址改成了被装载的 ELF 程序的入口地址。所以当 sys_execve()
从内核态返回到用户态时,EIP 寄存器直接跳转到了 ELF 程序的入口地址,于是新的程序开始执行,装载完成。
动态链接
为什么要动态链接
- 空间浪费:如果有多个程序引用了同一个模块,运行的时候,整个模块会存在多份副本
- 更新困难:一旦程序中有任何模块需要更新,整个程序就要重新静态链接,发布给用户
要解决上面两个问题,最简单的方法就是把程序的模块相互分割开来。简单来说就是,就是不对组成程序的目标文件进行链接,等到程序要运行的时候才进行链接,这就是动态链接的基本思想。
很明显上面的做法解决了共享多个目标文件多个副本浪费磁盘和内存空间的问题,另外它还可以减少物理页面的换入换出,也可以增加 CPU 的命中率。
同时上面的方案也可以使程序的升级变得更加容易,当需要升级或者修改某个模块,理论上只要简单的将旧的目标文件覆盖,而无需重新链接。
动态链接还有一个特点就是程序在运行时可以动态的选择加载各种程序模块,这个优点就是后来被人们用来制作程序的插件。
为什么不直接使用目标文件进行动态链接呢?
理论上是可行的,但实际上动态链接的方案和使用目标文件稍有差别。
- 在 Linux 中,ELF 动态链接文件被称为动态共享对象,简称共享对象,它们一般都是以 .so 为扩展名的一些文件
- 在 Window 中,动态链接文件被称为动态链接库,也就是常见的以 .dll 为扩展名的文件。
这样的做法的确很灵活,但是程序每次被装载时都要重新进行链接,这势必会导致性能损耗,但是对于动态链接的过程可以进行优化,比如我们后面要介绍的延迟绑定等方法。
动态链接例子
这里我们通过一个简单的例子来感受动态链接,需要使用到下面的程序:
1 | // programe1.c |
程序很简单,两个程序的主要模块 program1.c 和 program2.c 分别调用了 lib.c 里面的 foobar
函数。
我们先使用命令 gcc -fPIC -shared -o lib.so lib.c lib.h
将 lib.c 编译成一个共享对象文件。这个时候,我们分别编译 program1.c 和 program2.c:
1 | gcc -o program1 program1.c ./lib.so |
为啥这里链接需要 lib.so 的参与,不是说共享对象是在装载时进行链接吗?
这是因为当程序 program1.c 被编译成 program1.o 时,编译器还不知道foobar
的地址。当链接的时候,如果是静态链接,链接器会按照静态链接的规则将foobar
地址引用重定位;如果foobar
是一个定义在动态共享对象中的函数,那么链接器就会把这个符号标记为一个动态链接符号,不对它进行地址重定位,把这个过程推迟到装载的时候才进行。
之前我们说过了静态链接下的进程虚拟空间分布,但是对于动态链接,除了可执行文件本身,还有它所依赖的共享目标文件。我们来看看这种情况下的内存布局:
1 | xulei@xulei-virtual-machine:~/code$ ./program1 & |
我们可以看到,整个进程虚拟空间中多出了几个文件的映射。 lib.so 和 program1 一样都是操作系统用同样的方法映射至进程的虚拟地址空间。program1 除了使用到 lib.so 以外,它还用到了动态链接形式的 C 语言运行库:libc-2.31.so
。
另外还有一个值得关注的共享对象:ld-2.31.so
,它实际上是 Linux 下的动态链接器。动态链接器和普通共享对象一样,在系统开始运行 program1 之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作后再把控制权交给 program1,然后开始执行。
地址无关代码
固定装载的问题
共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址的空闲情况,动态分配一块足够大小的虚拟地址空间。那么共享对象在装载的时候是如何确定它在进程虚拟地址空间中的位置呢?
为了实现动态链接,首先遇到的问题就是共享对象地址的冲突问题。之前说过程序模块的指令和数据可能会包含一些绝对地址的引用,在链接产生输出文件的时候,就要假设模块被装载的目标地址。
很明显。动态链接的情况下,不同模块的装载地址肯定是不能相同的。对于单个程序来说,我们可以手动指定各个模块的地址,比如把某一块地址分给模块 A,另一块地址分给模块 B。但如果某个模块被多个程序使用,甚至多个模块被多个程序使用,那么管理这些模块的地址将会是一件无比复杂的事。
不幸的是,早期的确有不少系统使用了这种做法,这种做法叫做静态共享库。请注意,它和静态库有很明显的区别。静态共享库的做法就是将程序的各个模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一些地址块,为哪些已知的模块预留足够的空间。
装载时重定位
为了使共享对象能够在任意地址装载,首选想到的就是静态链接中的重定位。这个想法的思路就是,在链接时,对所有绝对地址的引用不作重定位,而是把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那系统就对程序中所有的绝对地址进行重定位。
在静态链接时提到过重定位,那时候的重定位叫做链接时重定位。而现在这种情况被称为装载时重定位,在 Windows 中,这种装载时重定位又被叫做基址重置。
但是装载重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享。这是因为装载时重定位的方法需要修改指令,没有办法做到同一份指令被多个进程共享,因为指令重定位后对每个进程来说都是不同的。
Linux 和 GCC 支持这种装载时重定位的方法,产生共享对象的命令如下:gcc -fPIC -shared -o target.so target.c
,如果只使用 -shared
,那么输出的共享对象就是使用装载时重定位的方式。-fPIC
的作用后面会说明。
地址无关代码
那么有没有一种更好的办法解决共享对象指令中绝对地址的重定位问题呢?我们的目的很简单,就是希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种技术目前被称为 地址无关代码(PIC)。
1 | static int a; |
我们来看看共享对象(动态链接库)模块中各种类型的地址引用方式:
- 模块内部的函数调用、跳转。例如上面的 bar 函数
- 模块内部的数据访问,比如模块中定义的全局变量、静态变量。例如上面的变量 a
- 模块外部的函数调用、跳转。例如上面的 ext 函数
- 模块外部的数据访问,比如其他模块定义的全局变量。例如上面的变量 b
当编译器在编译上面的代码,它实际上不能确定变量
b
和函数ext
是模块外部的还是模块内部的,因为它们可能被定义在同一个共享对象的其他目标文件中。由于无法确定,编译器只能把它们当做外部的处理。MSVC 编译器提供了__declspec(dllimport)
编译器扩展来表示一个符号是模块内部还是模块外部的。
模块内调用或跳转
因为被调用函数和调用者都处于同一个模块,它们之间的相对位置是固定的。对于现代的操作系统来说,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以这种指令是不需要重定位的。
这里还存在一个叫做共享对象全局符号介入的问题,这个会在后面动态俩链接的实现中介绍
模块内数据访问
一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内的数据了。
模块间数据访问
模块间数据访问比较麻烦,因为模块间数据访问目标地址需要等到装载时才决定。我们前面提到要把跟地址相关的代码放到数据段里面,很明显这些其他模块的全局变量的地址是跟模块装载地址有关的。ELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也被称作 全局偏移表(Global Offset Table,GOT)
当指令要访问 b 时,程序会先找到 GOT
,然后根据 GOT
中变量所对应的项找到变量的目标地址。每个变量都对应一个 4 字节的地址,链接器在装载模块的时候会查找每个模块所在的地址,然后填充 GOT
中的各个项。由于 GOT
是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。
从上面 模块内部的数据访问 中可以得知,模块在编译时可以确定模块内部变量相对于当前指令的偏移,那么我们也可以在编译时确定 GOT
相对于当前指令的偏移。和上面访问变量 a 一样,通过得到当前指令地址(PC)然后加上一个偏移就可以得到 GOT
表的地址。然后根据变量地址在 GOT
中的偏移就可以得到变量的地址,当然 GOT
中每个地址对应于那个变量是由编译器决定的。
模块间调用或跳转
对于模块间调用和跳转,也可以采用模块间数据访问的方式来解决。不同的是,GOT
中相应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT
中的项进行间接跳转,基本的原理如下图所示:
这个方法很简单,但是存在一些性能问题,实际上 ELF 采用了一种更加复杂和精巧的方法,后面的 动态链接优化 中会提到。
总结
总结一下 4 种地址引用方式:
-fpic和-fPIC:
使用 GCC-fPIC
参数就可以产生地址无关代码。GCC 还有一个-fpic
参数,功能与-fPIC
完全一样。唯一的区别是,-fPIC
产生的代码要大,而-fpic
产生的代码相对较小,而且较快。但由于地址无关代码都是跟硬件平台相关的,不同的平台有着不同的实现,-fpic
在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而-fPIC
则没有这样的限制。所以一般使用-fPIC
参数。
共享模块的全局变量问题
上面的情况中没有包含定义在模块内部的全局变量的情况。一种特殊情况:当一个模块引用了一个定义在共享对象的全局变量的时候,比如一个共享对象定义了一个全局变量 global
,而模块 module.c 中是这么引用的:
1 | extern int global; |
如上文所说,编译器无法判断 global
是否为跨模块间的调用。假设 module.c 是程序可执行文件的一部分,由于程序主模块的代码并不是地址无关代码,即不使用PIC 机制,它引用这个全局变量的方式跟普通数据访问方式一样。
由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的 .bss
段创建一个 global
变量的副本。导致同一个变量同时存在于多个位置中的问题。
于是解决的办法只有一个,那就是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。ELF 共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的 模块间数据访问,通过 GOT
来实现变量的访问。
当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT
中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。
如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本;如果该全局变量在程序主模块中没有副本,那么 GOT
中的相应地址就指向模块内部的该变量副本。
数据段地址无关性
数据部分也有绝对地址引用的问题,如:
1 | static int a; |
指针 p 的地址就是一个绝对地址,它指向变量 a,而变量 a 的地址会随着共享对象的装载地址改变而改变。 对于数据段来说,它在每个进程都有一份独立的副本,可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。
延迟绑定
我们都知道动态链接比静态链接慢的原因有:
- 动态链接下对于全局的数据访问需要进行复杂的
GOT
定位,然后间接寻址;对于模块间的调用也要先定位GOT
,然后再进行间接跳转。如此一来,程序的运行速度必然减慢 - 动态链接的工作在运行时完成,即程序开始执行时,动态链接器都要进行一次链接工作
动态链接下,程序模块之间包含了大量的函数引用,所以程序开始执行前动态链接器会耗费不少时间用于解决模块之间的函数引用的符号查找和重定位。不过有些函数可能在程序执行完后都没有被调用过,比如一些错误处理和很少用到的模块。如果一开始就把所有的函数都链接好其实是一种浪费,所以 ELF 采用了一种叫做延迟绑定(Lazy Binding)的做法,其基本思想就是函数第一用到的时候才进行绑定。
正常外部函数调用是通过 GOT
表中相应的项进行跳转,ELF 使用 PLT(Procedure Linkage Table)的方法实现延迟绑定。它在这个过程中增加了一层间接跳转,调用函数并不直接通过 GOT
跳转,而是通过一个叫做 PLT
项的结构进行跳转。每个外部函数在 PLT
中都有一个相应的项,比如 bar 函数在 PLT 中的项的地址称之为 bar@plt,让我们看看 bar@plt 的实现:
1 | bar@plt: |
链接器初始化阶段并没有把 bar 的地址存放在 bar@GOT
中 ,存放的是 push n 的地址,所以第一次调用的时候会跳到第二条指令也就是 push n。一旦 bar 这个函数解析完成,当我们再次调用 bar@GOT
时,第一个 jump 就能跳转到真正的 bar 函数中,bar 函数返回的时候会根据堆栈中保存的 EIP 直接返回到调用者,不会执行 bar@plt
中接下来的代码。
ELF 实际上将 GOT
拆分成了两个表叫做 .got
和 .got.plt
。其中 .got
用来保存全局变量引用的地址,.got.plt
用来保存函数引用的地址。也就是说,所有对于外部函数的引用全部被分离出来放到了 .got.plt
中。.got.plt
还有一个特殊的地方就是它的前三项是有特殊意义的,分别含义如下:
- 第一项保存的是
.dynamic
段的地址,这个段描述了本模块动态链接相关的信息 - 第二项保存的是本模块的
ID
- 第三项保存的是
_dl_runtime_resolve
的地址
其中第二项和第三项由动态链接器在装载共享模块的时候负责将它们初始化,.got.plt 的其余项分别对应每个外部函数的引用。这里描述的结构如下:
PLT 的结构也和例子中稍有不同,为了减少代码的重复,ELF 把最后两条指令放到 PLT 中的第一项。并且规定每一项的长度是 16 个字节,刚好用来存放 3 条指令,实际的 PLT 结构如下:
1 | PLT0: |
PLT 在 ELF 文件中以独立的段存放,段名通常叫做 .plt
,因为它本身就是一些地址无关的代码,所以可以和代码段合并成一个可读可执行的 Segment 被装载如内存。
动态链接相关结构
动态链接情况下,可执行文件的装载和静态链接情况基本一致,首先操作系统会读取可执行文件的头部,检查文件的合法性。然后从头部中的 Program Header 中读取每个 Segment 的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置,这些步骤和前面静态链接情况下的装载基本无异。静态链接下,操作系统接着就可以安保控制权交给可执行文件的入口地址,然后程序开始执行。
但在动态链接的情况下,操作系统还不能在装载完可执行文件后就把控制权交给可执行文件,因为我们知道可执行文件依赖于很多共享对象。这时候可执行文件里对于很多外部符号的引用还处于无效地址的状态,还没有跟相应的共享对象中的实际位置链接起来。所以在映射完可执行文件后,操作系统会先启动一个动态链接器。
在 Linux 下,动态链接器 ld.so
实际上是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器后,就像控制权交给动态链接器的入口地址(和可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权后,它开始执行一系列的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有的动态链接工作完成后,动态链接器会将控制权交到可执行文件的入口地址,程序开始正式执行。
.interp 段
那么系统中那个才是动态链接器呢?它的位置由谁决定?是不是所有的 *NIX
系统的动态链接器都位于 /lib/ld.so
呢?实际上动态链接器的位置既不是由操作系统指定,也不是由环境参数决定,而是由 ELF 可执行文件决定。在 ELF 文件中有一个段叫做 .interp
段,如果我们使用 objdump 工具查看,可以看到 .interp 的内容:
1 | lib.c lib.h lib.so program1 program1.c program2.c |
.interp
的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接的路径。在 Linux 下,可执行文件所需要的动态链接器的路径都是:
- 32 位:
/lib/ld-linux.so.2
- 64 位:
/lib64/ld-linux-x86-64.so.2
其他的 *nix
操作系统可能会有不同的路径。在 Linux 系统中,ld-linux-x86-64.so.2
是一个软链接,比如我的机器上,它指向 ld-2.31.so
。
动态链接器在 Linux 下是 Glibc 的一部分,也就是系统库的级别,它的版本号和 Glibc 库版本号一样,比如我的系统中安装的是 Glibc 2.31,那么相应的动态链接器就是 ld-2.31.so
。当系统中的 Glibc 库更新或者安装其他版本的时候,ld-linux-x86-64.so.2
就会指向新的动态链接器,可执行文件不需要修改自身 .interp
段中的路径来适应版本升级。
简单的查看可执行文件所需要的动态链接器路径:
1 | xulei@xulei-virtual-machine:~/code$ readelf -l program1 | grep interpreter |
.dynamic 段
ELF 中还有几个专门用于动态链接的,比如 .dynamic
段和 .dynsym
段等。动态链接中最重要的段就是 .dynamic
段,这个段里面保存了所有动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。.dynamic
也是一个结构体数组,结构体如下:
1 | typedef struct { |
三种常见的对应关系如下(d_tag 含义: d_un 含义):
- DT_SYMTAB:动态链接符号表的位置,d_ptr 表示 .dynsym 的地址
- DT_STRTAB:动态链接字符串表地址,d_ptr 表示 .dynstr 的地址
- DT_STRSZ:动态链接字符串表大小,d_val 表示大小
- DT_HASH:动态链接哈希表地址, d_ptr 表示 .hash 的地址
- DT_SONAME:本共享对象的 SO_NAME,后面会介绍 SO_NAME
- DT_RPATH:动态链接共享对象搜索路径
- DT_INIT:初始化代码地址
- DT_FINIT:结束代码地址
- DT_NEED:依赖的共享对象,d_ptr 表示所依赖共享对象文件名
- DT_REL、DT_RELA:动态链接重定位表地址
- DT_RELENT、DT_RELAENT:动态重读位表入口数量
我们前面看到 ELF 头中保存的是静态链接所需要的信息,这里换成了动态链接所需要的信息了。所以,.dynamic
段可以看成动态链接下 ELF 文件的文件头。
使用命令 readelf -d program1
可以查询 .dynamic 段的内容:
1 | 标记 类型 名称/值 |
另外 Linux 还提供了一个命令用来查看一个程序主模块或者一个共享对象库依赖于哪些共享对象库:
1 | xulei@xulei-virtual-machine:~/code$ ldd program1 |
linux-vdso.so.1
文件,并不是一个真实存在的文件,而是 Linux 中的一个虚拟文件,专门用于将内核中一些常用的函数从内核空间映射到用户空间。
动态符号表
为了完成动态链接,最关键的还是所依赖的符号和相关文件信息。我们知道在静态链接中,有一个专门的段叫做符号表 .symtab
,里面保存了所有关于该目标文件的符号的定义和引用。
动态链接的符号表实际上和静态链接的十分相似,ELF 专门有一个叫做动态符号表(Dynamic Symble Table)的段用来保存这些信息。与 .symtab
不同的是,.dynsym
只保存了和动态链接相关的符号,对于哪些模块内的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有 .symtab
和 .dynsym
两个表,.symtab
保存了所有符号,包括 .dynsym
中的符号。
与 .symta
类似,动态符号表也需要一些辅助的表,比如用于保存符号表名的字符串表。静态链接时叫做 .strtab
,在这里就是动态符号字符串表 .dynstr
(Dynamic String Table)。由于动态链接下,我们需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表(.hash
)。
比如前面的例子中 program1 程序依赖于 lib.so,引用到里其中的
foobar
函数。那么对于 program1 来说,我们往往称 program1 导入了 foobar 函数;而站在 lib.so 角度考虑问题,它实际上定义了foobar
函数,并且提供给其他函数使用,我们往往称 lib.so 导出了foobar
函数。
1 | // 查看符号表 |
重定位表
动态链接下,无论是可执行文件或共享对象,一旦它依赖于其他共享对象,也就是有导入的符号时,那么它的代码或者数据中就会有对于导入符号的引用。在编译时,这些导入符号未知,在静态链接中,这些未知的地址引用在最终链接时被修正。但是在动态链接中,导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。
对于动态链接来说,如果一个共享对象不是以 PIC 模式编译的,那么毫无疑问它是需要装载时被重定位的。如果一个共享对象是以 PIC 模式编译的,虽然它的代码段不需要重定位,但是数据段还包含了绝对地址的引用,例如代码段分离的 GOT
。
共享对象的重定位和前面静态链接中目标文件的重定位类似,唯一有区别的是目标文件的重定位是在链接时完成,共享对象的重定位是在装载时完成的。
静态链接中有用于专门表示重定位信息的重定位表,比如 .rela.text
表示的是代码段的重定位,.rela.data
表示的是数据段的重定位。动态链接中也有类似的重定位表,分别叫做 .rela.dyn
和 .rela.plt
。.rela.dyn
是对数据引用的修正,它所修正的位置位于 .got
以及数据段;而 .rela.plt
是对函数引用的修正,它所修正的位置位于 `.got.plt。
1 | // 命令查看动态链接文件的重定位表 |
进程堆栈初始化信息
从动态链接器的角度看,当操作系统把控制权交给它的时候,它将开始做链接工作,那么它至少需要知道关于可执行文件和本进程的一些基本信息。比如可执行文件有几个段,每个段的属性,程序的入口地址(因为动态链接结束后需要把控制权交给可执行文件)等。
这些信息往往由操作系统传递给动态链接器,保存在进程的堆栈里面。进程初始化的时候,堆栈里面保存了关于进程执行环境和命令行参数等信息,事实上,堆栈里面还保存了动态链接器所需要的一些辅助信息数据。辅助信息的格式也是一个结构体数组:
1 | typedef struct |
我们介绍几个常用比较常用的类值,并且是动态链接在启动时所需要的:
a_type 定义 | a_type 值 | a_val 的含义 |
---|---|---|
AT_NULL | 0 | 表示辅助信息数组结束 |
AT_EXEFD | 2 | 表示可执行文件的句柄。正如前面所提到的,动态链接器需要知道一些可执行文件的信息,当进程开始执行可执行文件时,操作系统会将可执行文件打开,这时候就会产生文件句柄。那么操作系统就可以把文件句柄传给动态链接器,动态链接器可以通过操作系统的文件读写操作访问可执行文件。 |
AT_PHDR | 3 | 可执行文件的程序头表在进程中的地址。正如前面 AT_EXEFD 所提到的,动态链接器可以通过操作系统的文件读写操作访问可执行文件,但事实上很多操作系统会把可执行文件映射到虚拟空间中,从而动态链接器不需要通过读写文件,而是可以直接访问内存中的文件映像。操作系统可以从这两个方式中选择一个,如果选择了映像的方式,操作系统必须提供后面的 AT_PHENT、AT_PHNUM 和 AT_ENTRY 这几个类型。 |
AT_PHENT | 4 | 可执行文件头中程序头表中每一个入口(ENTRY)的大小 |
AT_PHNUM | 5 | 可执行文件头中程序头表中入口(ENTRY)的数量 |
AT_BASE | 7 | 表示动态链接器本身的装载地址 |
AT_ENTRY | 9 | 可执行文件入口地址,即启动地址 |
那么辅助数组位于进程堆栈的那个位置呢?它位于环境变量指针的后面,如下图所示:
动态链接的步骤和实现
动态链接器实际链接步骤:
- 启动动态链接器自己
- 装载所有需要的共享对象
- 重定位和初始化
动态链接器自举
我们知道动态链接器本身也是一个共享对象,但是事实上它有一些特殊性。对于普通共享对象来说,它的重定位工作由动态链接器来完成,它也可以依赖其他共享对象,其中被依赖的共享对象由动态链接器负责链接和装载。可是对于动态链接器本身来说,它的重定位工作由谁来完成呢?
动态链接器很特殊,首先它本身不可以依赖其他任何共享对象;其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。对于第一个条件可以人为的控制,在编写的时候不依赖任何系统库、运行库;对于第二个条件动态链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作同时又不能用到全局和静态变量,这种具有一定限制条件的启动代码被称为自举。
动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码开始执行。自举代码首先会找到它自己的 GOT
,而 GOT 的第一个入口保存的即是 .dynamic
段的偏移地址,由此找到了动态链接器本身的 .dynamic
段。通过 .dynamic
中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始,动态链接器代码中才可以使用自己的全局变量和静态变量。
装载共享对象
完成基本自举后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表。前面提到过 .dynamic
段中,有一种类型的入口是 DT_NEEDED
,它所指出的是该可执行文件(或共享对象)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些对象放入到一个装载集合中。然后链接器开始从集合里找到一个所需的共享对象,找到相应的文件后打开该文件,读取相应的 ELF 文件头和 .dynamic
段,然后将相应的代码段和数据段映射到进程空间中。如果这个共享对象还依赖其他共享对象,那么所依赖的共享对象也会被放到装载集合中。
当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接所需要的符号。
动态链接器是怎么知道要装载的共享对象是否已经在内存中了呢?
答案是处于内核态的动态链接器不知道某个库是否已经加载进内存,真正知道并实现内存共享的是 Linux 内核。具体可以参考:动态链接器如何判断某个共享库已经加载进内存?
全局符号介入
一个共享对象的全局符号被另一个共享对象的同名全局符号覆盖的现象称为全局符号介入。关于全局符号介入问题,实际上 Linux 下的动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号会被忽略。
由于存在这种重名符号被直接忽略的问题,当程序大量使用共享对象的时候应该非常小心符号的重名问题,如果两个符号重名又执行不同的功能,那么程序运行时可能会将所有该符号名的引用解析到第一个被加入全局符号表的使用该符号名的符号,从而导致程序莫名的错误。
重定位和初始化
当上面的步骤完成后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的 GOT/PLT
表中每个需要重定位的位置修改。
重定位完成之后,如果某个共享对象有 .init
段,那么动态链接器会执行 .init
段中的代码,用以实现共享对象特有的初始化过程,比如常见的,共享对象中 C++ 的全局变量/静态对象的构造就需要通过 .init
来初始化。相应的,共享对象中还可能有 .finit
段,当程序退出时会执行 .finit
中的代码,可以用来实现类似 C++ 全局对象析构之类的操作。
如果进程的可执行文件也有 .init
段,那么动态链接器不会执行它,因为可执行文件的 .init
段和 .finit
段由程序初始化代码负责执行。
当完成了重定位和初始化之后,可执行文件所需要的共享对象都已经被装载并且链接完成了,这个时候动态链接器就可以将进程的控制权转交给程序的入口并且开始执行。
Linux 动态链接器实现
这里不展开实现细节,具体针对两个问题:
动态链接器本身是动态链接还是静态链接的?
动态链接库的作用是为了帮助其他 ELF 文件解决共享对象依赖问题的,所以它不能依赖其他的共享对象。1
2
3// 命令行检测 ld-linux-x86-64.so.2
xulei@xulei-virtual-machine:~/code$ ldd /lib64/ld-linux-x86-64.so.2
statically linked动态链接器必须是 PIC 的吗?
动态链接器可以是 PIC 也可以不是,但是使用 PIC 可以使得问题的解决更加简单。一方面,如果不是 PIC 的话,会使得代码段无法共享,浪费内存;另一方面也会使 ld.so 本身初始化更加复杂,因为自举还需要对代码段进行重定位。
显式运行时链接
加载动态链接库的两种方式:咱不知道的动态链接库小细节
支持动态链接的系统往往都会支持一种更加灵活的模块加载方式,叫做显式运行时链接,有时也叫运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且在不需要该模块时将其卸载。而且一般的共享对象不需要修改就可以进行运行是装载,这种共享对象往往被叫做动态装载库,其实本质上和一般的共享对象没有区别,只是开发者使用的角度不同而已。
在 Linux 中,从文件本身的格式来看,动态库实际上跟一般的共享对象没有区别。主要的区别是共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列的步骤都由动态链接器自动完成,对于程序本身是透明的;而动态库的装载则是通过一系列由动态链接提供的 API,具体的说有四个函数:打开动态库(dlopen
)、查找符号(dlsym
)、错误处理(dlerror
)以及关闭动态库(dlclose
),程序可以通过这几个 API 对动态库进行操作。
Linux 共享库
这里首先要解释一下共享库的概念,其实从文件结构上来说,共享库和共享对象没什么区别,Linux 下的共享库就是普通的 ELF 共享对象。
目前大多数包括 Linux 在内的开源操作系统都遵循一个叫做 FHS
(File Hierarchy Standard)的标准,这个标准规定了一个系统中的系统文件该如何存放,包括各个目录的结构、组织和作用。共享库作为系统中重要的文件,它们的存放位置也被 FHS 列入了规定范围。FHS 规定一个系统中主要有 3 个存放共享库的位置,它们分别如下:
/lib
:存放系统最关键和基础的共享库,比如动态链接器,C 语言运行库、数学库等,这些库主要是哪些 /bin 和 /sbin 下的程序所要用到的库,还有系统启动时需要的库。/usr/lib
:存放一些非系统运行时所需要的关键性的共享库,主要是一些开发时用到的共享库,这些共享库一般不会被用户的程序或者 shell 脚本直接用到。/usr/local/lib
:存放一些跟操作系统本身不十分相关的库,主要是一些第三方应用程序的库。例如 如果系统中安装了 Python 语言的解释器,那么与它相关的共享库可能会被放到 /usr/local/lib/python,而它的可执行文件可能被放到 /usr/local/bin 下。
GCC 提供了一种共享库的构造函数,只要在函数声明时加上 __attribute__((constructor))
的属性,即指定该函数为共享库构造函数,拥有这种属性的函数会在共享库加载时被执行,即在程序的 main 函数之前执行。与共享库构造函数相对应的是析构函数,可以在函数声明时加上 __attribute__((destructor))
的属性,这种函数会在 main 函数执行完毕后执行。
内存
内存布局
现代的应用程序都运行在一个内存空间里,在 32 位的系统里,这个内存空间拥有 4 GB 的寻址能力。但是大多数操作系统都会将 4 GB 内存空间中的一部分挪给内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间,Windows 在默认情况下会将高地址的 2 GB 空间分配给内核,而 Linux 默认情况下将高地址的 1 GB 空间分配给内核。
用户使用剩下 2 GB 或 3 GB 的内存空间称为用户空间,在用户空间里也有很多地址空间有特殊的地位,一般来说,应用程序使用的空间有如下区域:
- 栈:用来维护函数调用的上下文
- 堆:用来容纳应用程序动态分配的内存区域
- 可执行文件映像:存储着可执行文件在内存里的映像
- 保留区:并不是一个单一的区,而是对内存中受到保护而禁止访问的内存区域的总称,例如大多数操作系统里,极小的地址通常都是不允许访问的,如 NULL。
下图是 Linux 下一个进程典型的内存布局:
在图中,有一个区域叫:动态链接库映射区,这个区域用于映射装载的动态链接库。在 Linux 下,如果可执行文件依赖其他共享库,那么系统就会为它在从
0x40000000
开始的地址分配相应的空间,并将共享库载入到该空间。
栈与调用惯例
什么是栈
在经典的操作系统中,栈总是向下增长的。在 i386 下,栈顶由称为 esp
的寄存器进行定位。压栈的操作使栈顶的地址减小,弹出的操作使栈顶地址增大。
栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧(Stack Frame)或活动记录。堆栈帧一般包括以下内容:
- 函数的返回地址和参数。
- 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器。
在 i386 中,一个函数的活动记录用 ebp
和 esp
这两个寄存器划定范围。esp
寄存器始终指向栈的顶部,同时也就指向了当前函数活动记录的顶部。相对的,ebp
寄存器指向了函数活动记录的一个固定位置,ebp
寄存器又被称为帧指针(Frame Pointer)。一个很常见的活动记录示例如图所示:
固定不变的 ebp
可以用来定位活动记录中的各个数据,它指向的数据是调用该函数前 ebp
的值,这样在函数返回时,ebp
可以通过读取这个值恢复到调用前的值。之所以函数的活动记录会形成这样的结构,是因为函数调用本身导致的,i386 下的函数总是这样调用的:
- 把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递
- 把当前指令的下一条指令的地址压入栈中
- 跳转到函数体执行
其中第 2 步和第 3 步由指令 call 一起执行。跳转到函数体之后即开始执行函数,而 i386 函数体的标准开头是这样的:
- push ebp:把 ebp 压入栈中(称为 old ebp)。
- mov ebp, esp:ebp = esp(这时 ebp 指向栈顶,而此时栈顶就是 old ebp)。
- 【可选】sub esp, XXX:在栈上分配XXX字节的临时空间。
- 【可选】push XXX:如有必要,保存名为XXX寄存器(可重复多个)。
把 ebp
压入栈中,是为了在函数返回的时候便于恢复以前的 ebp
值。而之所以可能要保存一些寄存器,在于编译器可能要求某些寄存器在调用前后保持不变,那么函数就可以在调用开始时将这些寄存器的值压入栈中,在结束后再取出。
不难想象,在函数返回时,所进行的标准结尾与标准开头正好相反:
- 【可选】pop XXX:如有必要,恢复保存过的寄存器(可重复多个)。
- mov esp, ebp:恢复 ESP 同时回收局部变量空间。
- pop ebp:从栈中恢复保存的 ebp 的值。
- ret:从栈中取得返回地址,并跳转到该位置。
调用惯例
调用惯例指的是调用方和被调用方对于函数如何调用的约定,包含以下内容:
- 函数参数的传递顺序和方式,方式指使用栈还是寄存器等,顺序指从左到右或反过来。
- 栈的维护方式,规定由调用者还是被调函数清理栈(将压入的参数弹出)。
- 名字修饰(Name-mangling)的策略,用于链接时区分不同的调用惯例。
C 语言中存在多个调用惯例,默认的调用惯例是 cdecl
。几种主要的调用惯例:
调用惯例 | 出栈方 | 参数传递 | 名字修饰 |
---|---|---|---|
cdecl | 函数调用方 | 从右到左压栈 | 下划线+ 函数名 |
stdcall | 函数本身 | 从右到左压栈 | 下划线+函数名+@+参数的总字节数 |
fastcall | 函数本身 | 头两个DWORD(4byte)类型或者占更少字节的参数被放入寄存器,剩下的参数从右到左压栈 | @+函数名+@+参数的总字节数 |
pascal | 函数本身 | 从左到右压栈 | 较复杂,参见pascal文档 |
给出下面的例子:
1 | int f(int n, float m); |
如果是 cdecl 惯例,对于 f 函数来说,被修饰后就变为 _f 。在调用 f 的时候,按照 cdecl 的参数传递方式,具体的堆栈方式如下:
- 将 m 压入栈中
- 将 n 压入栈中
- 调用 _f
- 将返回地址(即调用 _f 之后的下一条指令的地址)压入栈
- 跳转到 _f 执行
上述例子完整的操作如下图所示(虚线指向该指令执行后的栈状态,实线表示程序的跳转):
再看一个多级调用的例子(箭头表示地址的指向关系,而带下划线的代码表示当前执行的代码):
1 | void f(int y) { |
函数返回值传递
除了参数的传递之外,函数与调用方的交互还有一个渠道就是返回值。正常情况下使用 eax
寄存器作为返回通道,函数将返回值存储在 eax
中,返回后函数的调用方再读取 eax
。但是 eax
本身只有 4 个字节,那么大于 4 字节的返回值是如何传递的呢?
对于返回 5~8 字节对象的情况,几乎所有的调用惯例都是采用 eax
和 edx
联合返回的方式。其中 eax
存储返回值的低 4 字节, edx
存储返回值的高 4 字节。
以下面的代码为例子:
- 首先 main 函数在栈上额外开辟了一片空间,并将这块空间的一部分作为传递返回值的临时对象,这里称为 temp
- 将 temp 对象的地址作为隐藏参数传递给 return_test 函数。
- return_test 函数将数据拷贝给 temp 对象,并将 temp 对象的地址用 eax 传出。
- return_test 返回之后,main函数将 eax 指向的 temp 对象的内容拷贝给 n。
1 | typedef struct big_thing { |
整个流程的伪代码如下:
1 | void return_test(void *temp) { |
下面是返回一个 C++ 对象的例子:
1 |
|
函数返回之后,进行了一个拷贝构造函数的调用,以及一次 operator=
的调用,也就是说,仍然产生了两次拷贝。因此C++的对象同样会产生临时对象。
正如我们所看到的的,在 C++ 里返回一个对象的时候,对象要经过 2 次拷贝构造函数的调用才能够完成返回对象的传递。一次拷贝到栈上的临时对象里,另一次把临时对象拷贝到存储返回值的对象里。
为了减小返回对象的开销,C++提出了返回值优化(Return Value Optimization, RVO)技术,可以将某些场合下的对象拷贝减少1次。返回值优化的做法是直接将对象构造在传出时使用的临时对象上,因此少一次复制。
1 | cpp_obj return_test() { |
堆的内存管理
光有栈是不够的的,因为栈上的数据在函数返回的时候就会被释放掉,所无法将数据传递至函数外部,这个时候就需要用到堆。
我们常用的 malloc 就是向堆申请空间,它是怎么实现的呢?有一种做法是把进程的内存管理交给操作系统内核去做,通过系统提供的系统调用就好。但是这样实现性能较差,因为每次申请都要系统调用,而系统调用性能较差。所以比较好的做法是程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理,具体来说,管理堆空间分配的往往是程序的运行库。
Linux 系统提供两种堆空间分配的调用:
brk()
系统调用,mmap()
系统调用
堆分配算法
如何管理一大块连续的空间,能够按照需求分配、释放其中的空间,这就是堆分配的算法。
- 空闲链表:把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,遍历整个链表,直到找到合适大小的块并且将它拆分;当用户释放空间时将它合并到空闲链表中。
- 位图:核心思想是将整个堆划分为大量相同大小的块(block),当用户请求内存的时候,总是分配整数个块的空间给用户, 第一块称为已分配区域的头(Head),其余的称为已分配区域的主体(Body)。使用一个整数数组来记录块的使用情况, 由于每个块只有“头/主体/空闲”三种状态,因此仅仅需要两位即可表示一个块。
运行库
入口函数和程序初始化
操作系统装载程序后,首先运行的第一行代码并不是 main 的第一行,而是某些特别的代码,这些代码负责准备好 main 函数执行所需要的环境,并且负责调用 main 函数。
运行这些代码的函数称为入口函数或入口点,程序的入口点是一个程序的初始化和结束部分,往往是运行库的一部分。典型的程序运行步骤大致如下:
- 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。
- 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等。
- 入口函数在完成初始化之后,调用 main 函数,正式开始执行程序主体部分。
- main 执行完毕以后,返回到入口函数进行清理工作,包括全局变量析构、堆销毁、关闭 I/O 等,然后进行系统调用结束进程
Linux glibc 的程序入口为
_start
,这个入口由 ld 链接器默认的链接脚本所指定的
I/O
I/O 全称是 Input/Output,即输入和输出。一个程序的 I/O 指代了程序与外界的交互,包括文件、管道、网络、命令行、信号等。
C 语言文件操作是通过一个 FILE 结构的指针进行。在操作系统层面,文件操作也有类似于 FILE 的一个概念,叫做 文件描述符(File Descriptor),在 Windows 中叫做 句柄。用户通过某个函数打开文件以获得句柄,此后用户操作文件皆通过该句柄进行。
在 Linux 中,值为 0、1、2 的 fd 分别代表标准输入、标准输出、标准错误。在程序中打开的文件得到的 fd
从 3 开始增长。fd
具体是什么呢?在内核中,每一个进程都有一个私有的 打开文件表,这个表是一个指针数组,每一个元素都指向一个内核的打开文件对象。而 fd
就是这个表的下标。当用户打开一个文件时,内核会在内部生成一个打开文件对象,并在这个表里找到一个空项,让这一项指向生成的打开文件对象,并返回这一项的下标作为 fd
。由于这个表处于内核,并且用户无法访问到,因此用户即使拥有 fd
,也无法得到打开文件对象的地址,只能够通过系统提供的函数来操作。
在 C 语言里,操纵文件的渠道则是 FILE 结构,不难想象,C 语言中的 FILE 结构必定和 fd
有一对一的关系,每个 FILE 结构都会记录自己唯一对应的 fd
。 FILE、fd
、打开文件表和打开文件对象的关系如图所示(内核指针 p 指向该进程的打开文件表):
I/O 初始化的职责就是在用户空间中建立 stdin、stdout、stderr 及其对应的 FILE 结构,使得程序进入 main 之后可以直接使用 printf、scanf 等函数。
运行库
C语言运行库
任何一个 C 程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其所依赖的函数所构成的函数集合。当然,它还理应包括各种标准库函数的实现。这样的一个代码集合称之为运行时库(Runtime Library)。而 C 语言的运行库,即被称为 C 运行库(CRT)。
一个C语言运行库大致包含了如下功能:
- 启动与退出:包括入口函数及入口函数所依赖的其他函数等
- 标准函数:由 C 语言标准规定的C语言标准库所拥有的函数实现
- I/O:I/O 功能的封装和实现
- 堆:堆的封装和实现,参见上一节中堆初始化部分。
- 语言实现:语言中一些特殊功能的实现。
- 调试:实现调试功能的代码。
C语言标准库
ANSI C 标准库由24个头文件组成,仅包含数学函数、字符/字符串处理,I/O 等基本方面,例如:
- 标准输入输出(stdio.h)
- 文件操作(stdio.h)
- 字符操作(ctype.h)
- 字符串操作(string.h)
- 数学函数(math.h)
- 资源管理(stdlib.h)
- 格式转换(stdlib.h)
- 时间/日期(time.h)
- 断言(assert.h)
- 各种类型上的常数(limits.h & float.h)
除此之外,C语言标准库还有一些特殊的库,用于执行一些特殊的操作,例如:
- 变长参数(stdarg.h)
- 非局部跳转(setjmp.h)
缓冲
缓冲常见于 IO 系统中,设想一下,当希望向屏幕输出数据的时候,由于程序逻辑的关系,可能要求多次调用 printf 函数,并且每次写入的数据只有几个字符,如果每次写数据都要进行一次系统调用,让内核向屏幕写数据,就明显过于低效了。
一个显而易见的可行方案是将对控制台连续的多次写入放在一个数组里,等到数组被填满之后再一次性完成系统调用写入,实际上这就是缓冲最基本的想法。
当读文件的时候,缓冲同样存在。我们可以在 CRT 中为文件建立一个缓冲,当要读取数据的时候,首先看看这个文件的缓冲里有没有数据,如果有数据就直接从缓冲中取。如果缓冲是空的,那么 CRT 就通过操作系统一次性读取文件一块较大的内容填充缓冲。这样,如果每次读取文件都是一些尺寸很小的数据,那么这些读取操作大多都直接从缓冲中获得,可以避免大量的实际文件访问。
写文件也同样存在相同的情况,而且写文件更加复杂。因为当我们通过 fwrite 向文件中写入一段数据的时,此时数据不一定被真正地写入到文件,而是有可能存在于文件的缓冲中,如果程序崩溃或进程意外退出时,有可能导致数据丢失,于是 CRT 提供了一系列与缓冲相关的操作用于弥补缓冲带来的问题。C 语言标准库提供与缓冲相关的几个基本函数如下:
1 | // 1.flush 指定文件的缓冲,若参数为 NULL,则flush所有文件的缓冲 |
所谓 fush
一个缓冲,是指对写缓冲而言,将缓冲内的数据全部写入实际的文件,并将缓冲清空,这样可以保证文件处于最新的状态。之所以需要 flus
h,是因为写缓冲使得文件处于一种不同步的状态,逻辑上一些数据已经写入了文件,但实际上这些数据仍然在缓冲中,如果此时程序意外地退出(发生异常或断电等),那么缓冲里的数据将没有机会写入文件。flush
可以在一定程度上避免这样的情况发生。
在这个表中我们还能看到 C 语言支持两种缓冲,即行缓冲(Line Buffer)和全缓冲 (Full Butfer)。全缓冲是经典的缓冲形式,除了用户手动调用们 flush
外,仅当缓冲满的时候,缓冲才会被自动 flush
掉。而行缓冲则比较特殊,这种缓冲仅用于文本文件,在输入输出遇到一个换行符时,缓冲就会被自动 flush
,因此叫行缓冲。
换行
不同的操作系统,回车略有不同:
- Linux:\n
- Mac OS:\r
- Windows:\r\n
在 C 语言中,回车始终用 \n 表示。