
自律是普通人最大的武器
| 系列专栏 | 直达链接 |
|---|---|
| 🐧 Linux | 进入专栏 → |
| 🔤 C语言 | 进入专栏 → |
| 🔧 C++ | 进入专栏 → |
| 🐬 MySQL | 进入专栏 → |
| 🧩 算法刷题 | 进入专栏 → |
🏠 博客主页:虚无的纽扣
目录
- 引言
- 1、什么是库
- [2、目标文件与 ELF 格式](#2、目标文件与 ELF 格式)
-
- 目标文件
- [ELF 文件](#ELF 文件)
- [ELF 形成可执行文件](#ELF 形成可执行文件)
- [为什么要将 section 合并成 segment](#为什么要将 section 合并成 segment)
- [Magic 与 readelf](#Magic 与 readelf)
- 3、理解链接与加载
-
- 静态链接
- [ELF 文件是如何加载到内存的](#ELF 文件是如何加载到内存的)
- 动态库是如何和我们的可执行程序关联的
- 动态链接
- 库间依赖
- 结语
引言
第三讲我们把文件在磁盘上怎么放理清了:从盘片、磁道到 inode,再到目录与软硬连接,不过那都是"存"这一侧的事。那我们写下的 main.c、还有 printf 这些函数所在的库,是怎么从一堆分散的文件变成一个能跑起来的程序的?这里牵扯到两个环节:链接和加载。这一篇先看"库"和 ELF 文件这些基础,再顺着链接与加载走一遍,看看可执行文件是怎么被组装、又是怎么被装进内存跑起来的
1、什么是库
我们平时写代码,几乎都离不开 printf、strlen 这些函数,可它们并不在我们自己写的源文件里,那它们是从哪来的呢?它们来自库。库是写好的、现有的、成熟的、可以复用的代码,现实里每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,所以库的存在意义非同寻常。本质上来说,库是一种可执行代码的二进制形式,可以被操作系统载入内存执行,而库又分两种:静态库和动态库,这两种我们后面分别来看
先看静态库。静态库本质就是打了包的 .o 文件,打包用的命令是 ar -rc,也就是归档,把一堆 .o 文件打包成静态库,其中选项 r 代表 replace,c 代表 create。得到的 .a 静态库,本质就是一种归档文件,使用时不需要使用者解包,直接用 gcc/g++ 链接即可
那另一种动态库呢?它是一个 .so 文件,和静态库最大的不同在于,动态库的代码不会在链接时被复制进可执行文件,而是等到程序运行时才被加载进来。既然两者各有各的用法,那如果一个库同时有静态版和动态版,链接器会挑哪个?答案是:如果同时存在动静态库,默认优先使用动态库,在 Linux 系统下,默认情况安装的大部分库,也都优先装的是动态库
为什么默认偏向动态库呢?因为动态库的代码在内存里只有一份,多个进程可以共享同一份,不像静态库那样每个程序都要复制一份,很占空间;而且库升级时,程序也不用重新链接。这也是系统更愿意默认给你装动态库的原因
总的来说:库其实就是将已经写好的代码编译成 .o 格式,并将它们封装到一个 .a 或 .so 文件中,在需要使用时直接与我们自己的 .o 文件进行链接就行
上面反复提到 .o,那这个 .o 到底是什么?它和库、可执行文件之间又是什么关系呢?
2、目标文件与 ELF 格式
目标文件
我们单独编译一个源文件时,gcc 会先生成一个 .o 文件,这个文件还不能直接运行,它叫做目标文件,和 Windows 下的 .obj 一样,都叫可重定位目标文件。那它为什么叫"可重定位"呢?后面讲到链接的时候,我们会再回到这个问题
ELF 文件
这里先说一个可能有点意外的事:动静态库、可执行程序、.o 文件,其实都是同一种格式,叫做 ELF 格式

一个 ELF 文件主要由这几部分构成:
1、ELF 头(ELF Header):描述文件的主要特征,位于文件的开始位置,主要目的是定位文件的其他部分
2、程序头表(program header table):列举了所有有效的段和它们的属性,表里记录着每个段的开始位置和位移、长度
3、节头表(section header table):包含对节的描述
4、节(section):ELF 文件中的基本组成单位,包含了特定类型的数据,ELF 文件的各种信息和数据都存储在不同的节中,比如代码节存储了可执行代码,数据节存储了全局变量和静态数据等
这里面有两个节是我们后面最常打交道的:代码节用于保存机器指令,是程序的主要执行部分;数据节保存已初始化的全局变量和局部静态变量
ELF 形成可执行文件
那多个源文件是怎么拼成一个可执行文件的呢?将多份 C/C++ 源代码翻译成目标 .o 文件,再连同用到的动静态库(本身也是 ELF),把多个 .o 文件的节(section)进行合并,就形成了一个大的可执行文件

一个 ELF 会有多种不同的 section,加载到内存的时候,也会进行 section 的合并,形成 segment,这样一来,即便原本是不同的 section,在加载到内存中,也可能以 segment 的形式加载到一起。很显然,这个合并工作在形成 ELF 的时候就已经确定了,具体合并原则被记录在了 ELF 的程序头表中
为什么要将 section 合并成 segment
section 合并的主要原因是为了减少页面碎片,提高内存使用率。如果不进行合并,假设页面大小为 4096 字节,.text 部分为 4097 字节,.init 部分为 512 字节,那 .text 自己就要占两个页面,因为 4097 字节已经超出了一页的容量,多出来的那 1 个字节也得再开一页;.init 又得单独占一个页面,加起来就是 3 个页面。合并之后就变成了 4097 + 512 = 4609 字节,两页就装得下,所以只需要两个页面。除了省页面,操作系统在加载程序时,也会将具有相同属性的 section 合并为一个大的 segment,这样就可以实现统一的访问权限,从而优化内存管理和权限访问控制
顺便说一句,程序头表就是告诉操作系统如何加载可执行文件、完成进程内存初始化的那张表,一个可执行程序中一定有一个程序头表
Magic 与 readelf
那我们怎么知道一个文件是不是 ELF 文件呢?ELF Header 中有一个数据叫做 Magic,用来表示当前文件是什么类型,操作系统在读取到这个数据时,就可以用对应的加载器来加载文件。ELF Header 存储的正是整个 ELF 文件中各个部分的格式信息

格式弄明白了,可它终究只是"静态"地躺在磁盘上的样子,真正让一堆 .o 变成一个可执行文件的,是链接这一步。那链接到底做了什么呢?
3、理解链接与加载
静态链接
无论是自己的 .o 还是静态库中的 .o,本质都是把 .o 文件进行链接的过程,所以研究静态链接,本质就是研究 .o 是如何链接的。这里先引入一个工具 objdump,它可以把我们的 .o 文件进行反汇编,反汇编之后我们会看到一个有点奇怪的现象:在 call 命令对应的机器码后面,表示地址的位置全都显示为 0
为什么是 0?因为目前这个位置要调用的方法我们还没有进行链接,说到底是我们在编译的时候,编译器完全不知道这些函数的存在,也不知道它们的地址,所以编译器只能暂时将它们都设置为 0。那这个地址在什么时候会进行修正?就是在链接的时候。为了让链接器在链接时能够正确定位到这些被修正的地址,在代码块中还存在一张重定位表,这张表将来在链接的时候,就会根据表里记录的地址把地址修正过来;而在符号表里,那些还没有着落的符号会被标记成 UND,意思就是"我这里要用它,但它在别的模块里"

所以所谓链接,就是每个模块拿着自己未定义的符号到其他模块去找,那如果用到的是库呢?原理完全一样,链接就是把库中的 .o 进行合并,和上述过程一样。链接其实就是将编译之后的所有目标文件,连同用到的一些静态库、运行时库组合,拼装成一个独立的可执行文件,其中就包括我们之前提到的地址修正:当所有模块组合在一起之后,链接器会根据我们的 .o 文件或者静态库中的重定位表,找到哪些需要被重定位的函数和全局变量,从而修正它们的地址,这其实就是静态链接的过程

现在回到开头那个问题:为什么 .o 文件会被叫做可重定位目标文件?因为在链接时它的地址会被重新修改,"可重定位"这三个字说的就是这件事
链接这一步把可执行文件组装好了,可它现在还在磁盘上躺着。那接下来它要怎么被装进内存、又是怎么被 CPU 执行起来的呢?
ELF 文件是如何加载到内存的
一个可执行程序,如果没有被加载到内存中,该可执行程序有没有地址?有地址。每个可执行程序都是多个数据节,我们只需要拿到每个节的起始地址,再拿到每个数据位置相对起始位置的偏移量,就掌握了所有数据的位置,只要每个节的起始位置不一样、不重叠,那么就可以完成对整个可执行程序在磁盘上进行编址,这个地址被称为逻辑地址
可是这样的地址计算起来会有点麻烦,所以操作系统和编译器在此基础上又进行了修改:如果每个 segment 的开始地址都是 0 呢?于是它们想了这么一个方法,将整个可执行程序看作一个大的 segment,这个大 segment 里有多个我们之前提过的小 segment,并且大 segment 的地址从 0 开始,那么所有的小 segment、所有函数、变量编址起始偏移量就都从 0 开始,这种编址模式叫做平坦模式编址
那又是怎么做到从 0 开始进行虚拟地址编址的呢?之前我们曾经也提到过这种方式,名为虚拟地址空间,虚拟地址空间不仅仅是进程看待内存的方式。磁盘上的可执行程序,代码和数据编址起始就是虚拟地址的统一编址,也就是说,虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统一编址了
之前在《每个进程都拥有"假内存" | 进程地址空间详解》里讲到的 mm_struct,在进程刚刚创建时它的初始化数据从哪来?就是从 ELF 的各个 segment 来。每一个 segment 都有自己的起始地址和自己的长度,用来初始化内核结构中的 start, end 等实际范围,另外再用详细地址填充页表

代码从磁盘加载到内存的时候,内存里每一行代码的物理地址也会被开辟出来,mm_struct 再利用虚拟地址初始化,把虚拟地址填入页表左侧,物理地址填入页表右侧,虚拟地址和物理地址的映射关系也就确定了
问题又来了,CPU 怎么知道从哪个地址位置开始是我们可执行程序的地址呢?CPU 里有一个寄存器 EIP,它负责存储下一个要执行的指令的地址,而在我们刚刚讲过的 ELF Header 中,有一个字段叫做 entry point address,这就是程序的入口地址。操作系统会直接将这个字段 load 到寄存器 EIP 里,CR3 寄存器负责指向我们当前程序的页表,CPU 再根据页表的映射关系到物理内存中去执行对应的代码,然后继续往下执行,遇到 call 指令时 call 指令又会传入一个新的地址,就又可以跳转到下一个地址去执行,这样 CPU 的运行逻辑就转起来了。需要注意的是,进到 CPU 里的地址全都是虚拟地址

加载的主线走到这里,自己写的代码怎么进内存已经清楚了,可我们的程序里还写着 printf 这样的库函数,它并不在我们自己的代码里,那动态库又是怎么和我们的可执行程序关联上的呢?
动态库是如何和我们的可执行程序关联的

动态库在被调用时也会被加载到我们的物理内存和虚拟地址空间中,因为动态库也是 ELF 文件,将来我们自己的代码需要调用库方法时,只要从代码区跳转到共享区的某个地址处,调用库函数再返回,就完成了库函数的调用。所以库函数调用可以拆成两步:1、被进程看到,也就是动态库映射到进程的地址空间;2、被进程调用,也就是在进程地址空间中进行跳转
那如果有两个进程呢?进程之间又是如何共享库的?

原理也与单个进程调用库的过程一样,所以动态库的本质,其实就是把公共的代码在系统层面抽取出来,动态库的代码不会在内存当中重复
上面讲的是"库怎么进到进程里",可还有一个细节没交代:库的加载地址不是一开始就定死的,它是运行时才确定的。那么链接这件事,到底是在什么时候完成的?这就是动态链接要解决的问题
动态链接
动态链接到底是如何工作的?动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库加载到内存,其中每个动态库的加载地址都不是固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。当动态库被加载到内存后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了
在 C/C++ 程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上,程序的入口点是 _start,这是一个由 C 运行时库或链接器提供的特殊函数,在 _start 函数中,会执行一系列初始化操作,这些操作包括:
1、设置堆栈:为程序创建一个初始的堆栈环境
2、初始化数据段:将程序的数据段从初始化数据段复制到相应的内存位置,并清零未初始化的数据段
3、动态链接:这是关键的一步,_start 会调用动态链接器的代码来解析和加载程序所依赖的动态库,动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址
Linux 系统通过环境变量和配置文件来指定动态库的搜索路径。动态库是随时可能被加载进来的,为了支持它映射到任意进程的任意位置,对动态库的方法也采用统一编址,采用相对编址的方法进行编址
那我们的程序具体是怎么进行库函数调用的呢?库已经被我们映射到了当前进程的地址空间中,库的虚拟起始地址我们也已经知道了,库中每一个方法的偏移量地址我们也知道,所以访问库中任意方法,只需要知道"库的起始虚拟地址 + 方法偏移量"即可定位库中的方法;而且整个调用过程,是从代码区跳转到共享区,调用完毕再返回到代码区,整个过程完全在进程地址空间中进行
text
同一个 printf,在三个阶段里,代码段是这三副样子
阶段 代码段里的调用形式 说明
------------------------------------------------------------
编译成 .o 时 call 0 还没链接,地址先留 0
静态链接之后 call 0x4005f0 地址已经写死进代码段
动态链接之后 call [GOT] 代码段不动,查 GOT 拿地址
也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道,然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置,这个叫做加载地址重定位。可是我们之前说过,代码区是只读的,无法被修改,那么该怎么解决这个问题呢?
动态链接采用的方法是在 .data 中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表 GOT,表中每一项都是本次运行模块要引用的一个全局变量或函数的地址,所以编译器就不用修改我们程序的代码段,而是改为从 GOT 表中获取地址和偏移量,这样只要修改 GOT 表中的数据,就能精确获取到我们想要的方法的地址。这种方式实现的动态链接就被叫做 PIC 地址无关代码,换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能正常运行,并且能够被所有进程共享,这也是为什么我们给编译器指定 -fPIC 参数的原因
库间依赖
不仅可执行程序会调用库,库也会调用其他库,库之间是有依赖的,那怎么做到库和库之间互相调用也是与地址无关的呢?答案还是 GOT:库中也有自己的 GOT 表,和可执行程序一样,也是 ELF 格式,所以库和库之间的调用,走的也是同一套办法
结语
这一篇我们从"库"讲起,分了静态库和动态库;再拆开 ELF 文件看它的四大组成;最后顺着链接与加载走了一遍,从 .o 的重定位一直到动态库的 GOT 表,把"一堆文件怎么变成能跑的程序"串了起来
以后我会继续创作 Linux 方向的博客,如果觉得有帮助的话,希望各位大佬能三联关注支持一下,看到后我会进行回关和回访