《库制作与原理》

学习重点:

  1. 动静态库的制作
  2. 动静态库的使用
  3. 动态库的查找
  4. 可执行程序ELF格式
  5. 可执行程序的加载过程
  6. 虚拟地址空间和动态库加载的过程

一: 什么是库?

在学习库之前我们先来认识一下什么是库:

库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。

本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:

  1. 静态库: .a(Linux).lib(windows)
  2. 动态库: .so(Linux)、.dll(windows)

当然只看这个定义的话大家可能有点懵,这是在说啥啊,所以接下来我们直观来感受.

我们先来看一下我们熟悉的C语言标准库:

这里我们写了一个简单程序,然后用ldd指令来查看这个程序的信息,可以看到这里依赖的库就是C语言标准库.

库的本质?

这里我们可以通过一个指令来查看静态库的内容:
ar - tv 静态库

由于静态库里面包含的文件很多,这里就截了一部分,可以库里面包含着很多的.o文件,

此时我们得出一个结论: 库的本质就是.o文件的集合.

其实库并没有大家想的那么神秘啊.

二: 静态库

静态库介绍:

  1. 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。
  2. 一个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,而我们的编译默认为动态链接库,只有在该库下找不到动态.so的时候才会采用同名静态库。我们也可以使用gcc的-static 强转设置链接静态库。

1. 制作静态库(站在设计者角度)

(1)思考: 我应该给用户提供什么?

站在设计者角度来思考一下我们在设计库的时候,应该给用户提供哪些东西?

首先,毋庸置疑的就是一定要有库,并且这个库一定是被封装起来的,不然那么多的.o文件,如果丢一个就不能使用了,其次还需要提供什么呢? 其实不难想到,还应该给用户提供.h头文件,因为这些.o文件都是二进制文件,用户可看不懂,为了确保用户知道我们提供的库里面都有哪些方法,我们就需要提供对应的头文件,也就相当于现实生活中的说明书,告诉使用者我们的库都有哪些方法,以及怎么使用.

(2)封装静态库

由于现在只是为了说明原理,所以就直接复用前面我们封装的libc文件了.
ar - rc : 封装静态库
argnu 归档⼯具, rc 表示 (replace and create)

makefile文件:

这样我们的一个静态库就封装好了,并且我们还提供了对应的头文件.

2. 使用静态库(站在用户角度)

这里我们把提供好的静态库拷贝到用户的目录下:

既然给我提供了静态库,那我直接用不完事了吗,于是我们就写一个简单的测试程序来试试:

接着直接运行:

啊? 这怎么会给我报这种错误啊? gcc 不认识这个头文件,这是因为gcc默认只认识C语言的标准库,我们自己写的头文件它不认识

(1) 方法一: -I -L -l

-I 选项: 告诉gcc去哪里找这些头文件

在加上-I 选项之后,gcc知道这些头文件去哪里找了,但是这里出现了新的报错,gcc找不到这些方法的定义,这是为什么呢? 相同的原因,因为gcc不认识啊,我们还需要告诉它去那里找这些定义:
-L

我们加上 -L选项之后,这怎么还是报错啊? 这是因为gcc不认识我们的库,我们还需要告诉它我们库的名字(这里要去掉前缀和后缀):
-l

此时用户就能正常使用我们封装的静态库了.

到这我们就能得出一个使用静态库的方法了: -I -L -l

(2) 方法二: 将静态库拷贝到系统目录中

由于gcc只认识C语言标准库,因此它只会去特定路径下去找,于是我们就需要将其拷贝到对应目录.

将库文件拷贝到对应路径:

将头文件拷贝到对应路径:

接着我们运行看看:

此时还是报错: 找不到这些定义,也就是找不到库文件,这是因为指定目录下的库文件有很多,它不认识我们的库,这时候还需要-l选项来告诉它:

此时就没问题了.

(3) 建立软连接:

我们先将之前拷贝的那个库文件删掉:

可以看到此时不能正常使用我们的库文件了.

然后我们建立软连接,并查看是否成功建立:

之后我们运行:

可以看到我们还是需要带-l才能正常使用.

并且这里即使建立库的软连接,还是需要将对应的头文件拷贝到对应的目录下,或者也建立像这样的软连接.

补充细节:

这里的gcc在查找库的时候,默认库文件前缀带lib,因此如果你的库文件不带这个前缀很可能会找不到,

二: 动态库

动态库介绍:

  1. 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
  2. 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码,在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking
  3. 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。

1. 制作动态库:

  1. shared:表示生成共享库格式
  2. fPIC:产生位置无关码(position independent code)(现在还解释不了,后面再学)
  3. 库名规则:libxxx.so

2. 动态库使用:

动态库的使用与静态库并无两样,于是我们这里就不再介绍编译时的使用方式了,这里我们直接将其拷贝到用户目录进行使用.

编译倒是成功了.但是运行不了,并且这里查看程序信息的时候也显示mystdio库找不到,这是为啥啊?

我们前面不是已经告诉gcc了吗? 为啥这里运行不了啊?

那么首先来想一个问题,这里是谁运行的? 是gcc吗?

哦哦,这里压根和gcc没一点关系了啊,这里是进程在运行,也就是bash在运行,是bath找不到我们的动态库啊,那这要咋整啊?

(1) 方法1: 将动态库拷贝到对应的系统目录下:
(2) 方法2: 建立软连接
(3) 方法3: 更改环境变量
(4) 方法4:创建全局配置文件


三: 动静态库链接规律

1. 两者都存在时:

此时默认是动态链接

(2) 强制静态链接:


-static强制静态链接

(3) 只存在静态库时:

通过观察这里的程序大小,不难发现当只存在静态库时,对于该库来说被迫只能静态链接,而其他库默认还是动态链接.

四: 目标文件

这里的.o文件就是目标文件

五:ELF文件

1. 概念

要理解编译链链接的细节,我们不得不了解⼀下ELF文件。其实有以下四种文件其实都是ELF文件:

  1. 可重定位文件(Relocatable File) :即xxx.o文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。
  2. 可执行文件(Executable File) :即可执行程序。
  3. 共享目标文件(Shared Object File) :即xxx.so文件。
  4. 内核转储(core dumps) ,存放当前进程的执行上下文,用于dump信号触发。

一个ELF文件由以下四部分组成:

  1. ELF头(ELF header) :描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。
  2. 程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中,需要段表的描述信息,才能把他们每个段分割开。
  3. 节头表(Section header table) :包含对节(sections)的描述。
  4. 节(Section ):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。

最常见的节:

  1. 代码节(.text):用于保存机器指令,是程序的主要执行部分。
  2. 数据节(.data):保存已初始化的全局变量和局部静态变量。

2. 结构图:

3. 指令查看:

(1) 查看Section Header Table

readelf -S 程序名
-S--section-headers:显示ELF文件的节头信息。节头描述了ELF文件的各个节的起始地址、大小、标志等信息

(2) 查看ELF Header

readelf -h 程序名
-h--file-header:显示ELF文件的文件头信息。文件头包含了ELF文件的基本信息,比如文件类型、机器类型、版本、入口点地址 、程序头表和节头表的位置和大小等

注意这里的入口地址,很重要后面是个要点.

(3) 查看Program Header Table

readelf -l 程序名
-l--program-headers:显示ELF文件的程序头部(也称为段头)信息。可执行文件在内存中的布局和加载过程非常重要。

这里结尾还会有这些节形成的段的信息.

(4) 查看具体的Sectors信息

objdump -S 程序名

4. 形成:

(1) ELF形成可执行
  1. :将多份 C/C++ 源代码,翻译成为目标 .o 文件 + 动静态库(ELF)
  2. :将多份 .o 文件section进行合并
(2) ELF可执行文件加载
  1. 一个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment
  2. 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等.
  3. 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
  4. 很显然,这个合并工作也已经在形成 ELF 的时候,合并方式已经确定了,具体合并原则被记录在了 ELF 的 程序头表(Program header table) 中
思考: 为什么要进行合并呢?
  1. Section合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为4096字节(内存块基本单位,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个页面。
  2. 此外,操作系统在加载程序时,会将具有相同属性的section合并成一个大的
    segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。

5. 思考程序头表和节头表的作用:

  1. 链接视图(Linking view) -对应节头表 Section header table
    文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解ELF文件中包含的各个部分的信息。
    为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等。合并了后,空间利用率就高了,否则,很小的很小的一段,未来物理内存页浪费太大(物理内存页分配⼀般都是整数倍一块给你,比如4k),所以,链接器趁着链接就把小块们都合并了。
  2. 执行视图(execution view) -对应程序头表 Program header table 告诉操作系统,如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中,⼀定有program header table 。
  3. 说白了就是:一个在链接时作用,⼀个在运行加载时作用。

6. 理解链接与加载:

(1) 前置知识:
a. 问题1: 创建一个进程,是先创建对应的内核数据结构还是先加载其ELF文件?

这个问题其实之前就提到过,当然是先创建对应的内核数据结构了.

b. 问题2: 程序没有被加载到内存中之前,程序自己有地址吗? 是什么地址?

如果之前用过vs编译器调试的话,这个问题其实不难回答,程序是有地址的,但肯定不是物理地址,而是虚拟地址.

这里给一个结论: 在编译器中,其会对可执行程序中的每一行代码都进行编址,原则是从0地址开始的
这里由于OS遵守虚拟地址空间,于是就倒逼编译器也需要遵守这个规则.

c. 问题3: 这里的内核数据结构只创建吗? 要不要初始化?

这个问题放到后面解答.

(2) 初步认识:

首先磁盘中的ELF文件会加载到内存中,注意这里它自己是有虚拟地址的,当它的数据加载到内存中后,会得到对应的物理地址,这时候就能把页表填上了,映射关系就有了.当所有数据都加载进来之后,就能更新PCB中的虚拟地址空间表了,因为这个表就是那几张数据的分区啊. 这下是不是就明白了这张表是哪里来了的呢?

问题1: 如果ELF文件不加载到内存中,那此时又怎么初始化PCB呢?

但此时可能会有同学提出问题: 如果ELF文件没有加载到内存呢? 此时PCB又要怎么更新呢? 这时候就要重提之前在ELF文件头中的那个地址入口了,注意这个入口地址是虚拟地址啊,那么PCB不就可以通过这个地址入口访问ELF文件了吗? 区域划分不就有了吗?

问题2: 这里的CPU怎么知道处理到哪条数据了呢? CPU是怎么工作的呢?

其实这里的CPU中存在一个PC指针,一开始是会指向数据开始位置,此时将该位置数据的虚拟地址传入CPU,之后CPU中的另一个区域MMU会在页表中完成映射工作(这里是有一个区域CR3指向页表),之后就找到了要处理的数据的物理位置,输出物理地址,然后交给相关负责区域处理完之后,PC指针会被更新,接着处理下一条数据.

因此CPU接收的都是虚拟地址,输出的都是物理地址.

(3) 进程怎么看到动态库:
(4) 进程间是怎么共享动态库的?

既然动态库能映射一个进程,那么多进程也同样如此.

因此动态库只需要在内存中存在一份就能让多进程共享.

7. 静态链接:

(1) 编译:

首先我们创建两个测试文件:

run.c文件

main.c文件:

接着现将其编译成.o文件然后查看其反汇编:

查看反汇编的指令: objdump -d xxx.o文件


可以看到这里的call指令位置的地址为空.

接着再查看符号表:

指令: readelf -s xxx.o文件

这里也可以看到main.o文件里面的run函数调用是UND状态,也就是未定义状态.

因为main.c里面并没有run函数的定义,只有其声明. 但是我们的确是编译成功了.

至此我们能得出一个链接前的结论: 多个.o文件彼此是互补知晓的,编译成功只是编译器先将未确定的地址先搁置为0了,

(2) 链接:

之后我们将其链接成一个可执行程序(注意这里要用静态链接)之后再来查看符号表:

此时可以看到之前的空地址就已经被填上了.

结论 : 静态链接就是把库中的.o进行合并,和上述过程⼀样.

所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。

因此静态链接并不存在加载过程,因为在链接完之后就不再依赖于静态库了,在链接的时候工作就已经完成了,

(3) 示意图:

8. 动态链接:

(1) 前置问题:
a. 问题1: 进程创建的时候,是先有内核数据结构,还是先加载程序?

这个问题前面也是涉及过,当然是先有内核数据结构,

b. 问题2: 动态链接的程序,是先加载程序,还是先加载程序依赖的库?

这个问题如果结合一下我们之前遇到的动态链接程序直接运行失败的情况不难理解,

为什么会运行失败呢? 是因为OS找不到对应的动态库,因此动态链接时,一定是先加载程序依赖的动态库. 那怎么找到这些库呢? 这些库本质还是磁盘文件啊,所以一定是从磁盘中去找,那就经过路径解析,找到对应的iNode编号,然后找到对应的信息结构体,再通过其维护的信息找到对应的数据块,然后加载到内存中,这一系列步骤前面已经学过,不再详细赘述.

(2) 总体逻辑:

这里先从磁盘中加载动态库到内存里,再加载你的代码和数据,动态库加载到内存中就有了物理地址,这时候就可以通过页表与虚拟地址空间表建立起映射关系,动态库的虚拟地址会放到虚拟地址空间表中的共享区,每个进程都有一张虚拟地址空间表,那建立起多个映射关系不就得了,这时候就实现了内存中一份动态库但是可以多进程共享.

a. 思考1: 进程怎么知道此时需要的动态库是否加载到内存里了呢?

先来想一个问题,内存中加载这么多动态库,OS要不要管理呢? 怎么管理呢?

先描述,再组织. 因此一定有对应的数据结构来维护这些动态库,进程在运行时先查一下,看是否在内存中存在其需要的动态库,如果存在,直接映射,如果不存在,那就先加载,再映射.

b. 思考2: 这里的动态库的虚拟地址是放到了共享区,那OS在执行代码区时是怎么做到链接动态库的呢?

其实不难理解,无非就是需要链接的时候在虚拟地址空间内部进行函数跳转呗,之后再跳回继续执行代码区呗.

因此进程的库函数调用,本质还是在进程的虚拟地址空间内进行的函数跳转.

(3) 细节刻画:
动态库前置知识:
示意图1:

先通过路径解析拿到动态库的iNode编号,接着找到数据块,然后将动态库加载到内存中,之后与虚拟地址空间表通过页表建立起映射关系,之后类似的步骤陆续将代码和数据加载到内存中,这样虚拟地址空间表中的代码区和数据区也就被初始化了,之后就是执行过程,通过代码区的信息,页表映射找到代码的物理地址就能执行. 如果需要链接动态库,就先跳转到共享区,通过页表映射找到动态库的物理位置,这样就实现了动态链接,

也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道,然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位)

但是到这里有同学可能就有问题了.

a. 问题1: 这里在跳转到共享区的时候,你是怎么知道哪个方法在哪里的呢?

前面我们不是了解到动态库中的每个方法都有一个逻辑偏移地址吗? 这里所谓的初始化虚拟地址空间表中的共享区,拿到的就是动态库的起始地址啊,在进行库函数跳转的时候,我们是知道动态库的起始地址的,然后页表映射就能找到动态库在物理内存中的位置,再加上对应方法的逻辑偏移地址,此时不就知道对应的方法在哪里了吗?

.

b.问题2: 这里的地址重定位不就是改变call指令后面的地址吗? 这个不是代码区吗? 代码区不是只读的吗?

所以:动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。

因为.data区域是可读写的,所以可以支持动态进行修改

在加载的过程中会和.data合并.

于是这里就引出了第二种示意图:

示意图2:
  1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
  2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。
  3. 在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
  4. 这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT
    (这里的相对编址可以结合CPU中的PC指针来理解)

其实换句话说,动态链接就是把链接的过程拖到了加载过程里.

9. 动态理解与静态链接总结:

  1. 静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。
  2. 我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成⼀个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
  3. 而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用(运行重定位,也叫做动态地址重定位)。
相关推荐
ZhengEnCi2 小时前
L1D-Linux系统Node.js部署Claude Code完全指南 🚀
linux·ai编程·claude
hnxaoli2 小时前
统信小程序(十一)快捷地址栏
linux·python·小程序
黄昏晓x2 小时前
Linux----网络
linux·网络·arm开发
小比特_蓝光3 小时前
Linux开发工具
linux·运维·服务器
大熊背3 小时前
ISP离线模式应用(二)-如何利用 ISP 离线模式 加速 3DNR 收敛
linux·算法·rtos·isp pipeline·3dnr
岁岁种桃花儿3 小时前
AI超级智能开发系列从入门到上天第十篇:SpringAI+云知识库服务
linux·运维·数据库·人工智能·oracle·llm
AttaGain3 小时前
【Ubuntu配置VLAN网络】
linux·网络·ubuntu
ljh5746491194 小时前
Linux find命令
linux·运维·chrome
纪伊路上盛名在4 小时前
Zerotier-Tailscale 自动化监控
linux·运维·自动化·内网穿透