一、什么是库
1、动静态库概念
# 库是写好的现有的,成熟的,可以复⽤的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个⼈的代码都从零开始,因此库的存在意义⾮同寻常。
# 本质上来说库是⼀种可执⾏代码的⼆进制形式,可以被操作系统载⼊内存执⾏。库有两种:
- 在
Linux
当中,以.so
为后缀的是动态库,以.a
为后缀的是静态库。- 在
Windows
当中,以.dll
为后缀的是动态库,以.lib
为后缀的是静态库。
# 问题:动静态库里面是否需要包含main函数? 答案是:库函数中不要包含main函数,我们链接过程会把库函数链接,如果库函数还有main函数,就会导致命名冲突导致链接失败,所以不要!
2、动静态库优缺点
# 静态库在程序编译时被链接到目标代码中。一旦链接完成,静态库的代码就成为目标程序的一部分。这意味着如果多个程序都使用了同一个静态库,那么每个程序都会包含一份该库的副本,从而导致程序体积较大。
优点:
- 独立性强,不依赖外部环境,因为库代码已经被包含在程序中。
- 运行时加载速度相对较快,因为不需要在运行时进行库的加载操作。
缺点:
- 生成的程序体积较大。
- 如果静态库有更新,需要重新编译链接所有使用该库的程序
# 动态库在程序运行时被加载。多个程序可以共享同一个动态库,只有当程序运行时才会将动态库加载到内存中。这大大减小了程序的体积,同时也方便了库的更新和维护。
优点:
- 生成的程序体积较小,因为库代码没有被包含在程序中。
- 库的更新不影响已编译的程序,只需要更新动态库文件即可。
缺点:
- 依赖外部环境,运行时需要确保动态库存在且路径正确。
- 加载动态库可能会带来一定的时间开销。
3、动静态库原理
# 我们知道,一个源文件变为一个可执行文件将经历四个步骤:
- 预处理: 完成头文件展开、去注释、宏替换、条件编译等,最终形成xxx.i文件。
- 编译: 完成词法分析、语法分析、语义分析、符号汇总等,检查无误后将代码翻译成汇编指令,最终形成xxx.s文件。
- 汇编: 将汇编指令转换成二进制指令,最终形成xxx.o文件。
- 链接: 将生成的各个xxx.o文件进行链接,最终形成可执行程序。
# 比如我们现在有test1.c
,test2.c
,test3.c
,以及main1.c
这四个.c
文件,经过预处理,编译,汇编之后分别生成test1.o
,test2.o
,test3.o
,以及main1.o
这四个.o
文件。最后经过生成a.out
的可执行文件。
# 但是此时我们的main2.c文件的生成同时也需要依赖test1.c,test2.c,test3.c这三个文件,生成可执行程序的步骤都是一样的。此时我们就可以选择将test1.c,test2.c,test3.c这三个文件生成的test1.o,test2.o,test3.o进行打包,之后再使用时,只需要链接这个"包"即可,这个"包"其实就是我们常说的库。
# 所以动静态库的本质其实是一堆xxx.o
文件的集合。对于库的使用,只需要提供头文件让使用者了解具体功能的作用。在编译程序时,通过链接指定的库来实现对库中功能的调用。
二、静态库
1、静态库的打包
# 接下来我们以使用之前写过的库函数缓冲区代码为例,讲解一下我们如何将我们的文件打包成静态库:
# 然后我们需要将mystdio.h
,mystdio.c
,mystring.h
,mystring.c
这4个文件打包成静态库。
1. 首先第一步将源文件生成对应.o文件。
2. 第二步使用ar指令打包成对应的静态库。
# 其中ar是gun归档工具,ar
指令用法为ar 选项 库名 打包文件名
,其中又两个关键选项:
-r
(replace):若静态库文件当中的目标文件有更新,则用新的目标文件替换旧的目标文件-c
(create):建立静态库文件
# 其中需要注意的是:动静态库真实文件名需要去掉前缀lib
,再去掉后缀.so
或者.a
及其后面的版本号,比如说libc-2.17.so
就是C语言的标准库,其名为: c
。
3. 将头文件和生成的静态库组织起来。
# 当把自己的库提供给他人使用时,通常需要给予两个文件夹:
- 一个文件夹用于存放头文件集合。比如,可以将mystdio
.h
和mystring.h
这两个头文件放置在名为include
的目录下。(头文件本质是对源文件方式的使用说明文档)- 另一个文件夹用于存放所有的库文件。例如,把生成的静态库文件
libmyc.a
放到名为mylib
的目录下。
# 最后,将这两个目录(include
和mylib
)都放置在lib
目录下,此时就可以把lib
提供给别人使用了。
4. 将lib打包。
# 此时我们的lib.tgz就相当于一个安装包了,下载过去就可以使用。
# 为了方便我们处理,我们可以写一个Makefile:
libmyc.a:mystdio.o mystring.oar -rc $@ $^%.o:%.c #展开所有.c文件生成对应的.o文件gcc -c $<.PHONY:clean
clean:rm -rf ./*.o libmyc.a lib.tgz.PHONY:output #发表库
output:mkdir -p lib/includemkdir -p lib/mylibcp -f ./*.h lib/includecp -f ./*.a lib/mylibtar czf lib.tgz lib
2、静态库的使用
# 首先我们将lib.tgz解压。
# 我们如果使用我们打包的静态库,在使用gcc
编译时需要带有以下三个选项:
-I
:指定头文件搜索路径。-L
:指定库文件搜索路径。-l
:指明需要链接库文件路径下的哪一个库。
# 由于在程序执行时,编译器并不知晓我们所声明的头文件以及链接库的具体位置,而且链接库中可能存在不同的库文件。因此,我们需要在命令行中指定头文件的搜索路径,库文件的搜索路径,以及具体使用哪个库。
# 比如我们需要执行main.c
,其中main.c
中使用静态库中的函数。
#include "mystdio.h"
#include "mystring.h"#include <stdio.h>int main()
{const char *s = "abcdefg";printf("%s: %d\n", s, my_strlen(s));MyFile *fp = mfopen("./log.txt", "a");if(fp == NULL) return 1;MyFwrite(s, my_strlen(s), fp);MyFwrite(s, my_strlen(s), fp);MyFwrite(s, my_strlen(s), fp);MyFclose(fp);return 0;
}
# 其中需要注意的是,-I
,-L
,-l
这三个选项后面可以加空格,也可以不加空格。
# 那么我们就有个疑问,那就是我们平时使用gcc编译文件时为什么没有带-I
,-L,-l
这三个选项呢?
# 其实很简单,因为我们之前使用的库都默认在系统的路径下,编译器能准确识别这些存在于配置文件中的路径,系统搜索头文件的路径在/usr/include目录下,搜索库文件在/lib.64目录下。其实如果为了方便我们也可以将头文件和库文件拷贝到系统路径/usr/include,/lib.64下:
- sudo cp lib/include/* /usr/include/
- sudo cp lib/lib/* /lib.64/
# 这时再使用gcc
编译时就只需要带-l
选项,指明链接库文件下具体哪个库。
# 但是实际上,我们并不推荐将自己写的头文件和库文件拷贝到系统路径下,因为这样做可能会对系统文件造成污染。
三、动态库
1、动态库的打包
# 动态库的打包相对于静态库较为复杂,但大致相同,我们还是利用mystdio.h
,mystdio.c
,mystring.h
,mystring.c
这4个文件进行打包演示:
1. 首先第一步将源文件生成对应.o文件。
# 但是与静态库不同的是,需要带-fPIC
选项,因为动态库运行时才会被加载。
<font style="color:rgb(28, 31, 35);">-fPIC(position independent code)</font>即产生位置无关码,作用于编译阶段,其目的是告诉编译器生成与位置无关的代码。在这种情况下,所产生的代码中不存在绝对地址,全部采用相对地址(起始位置加上偏移量)。这使得动态库被加载器加载到内存的任意位置时都能够正确执行。倘若不添加该选项,代码中使用的库函数在执行时会尝试调到对应位置执行,但此时可能会因该位置被其他动态库所占用而找不到该函数。
2. 使用-shared选项将所有目标文件打包为动态库。
# 生成对应的动态库并不需要使用ar
指令,还是使用gcc
编译,只不过需要带-shared
选项。
3. 将头文件和生成的动态态库组织起来。
# 与静态库类似,当把自己的库提供给他人使用时,通常需要给予两个文件夹:
- 一个文件夹用于存放头文件集合。比如,可以将mystdio
.h
和mystring.h
这两个头文件放置在名为include
的目录下。- 另一个文件夹用于存放所有的库文件。例如,把生成的静态库文件
libmyc.so
放到名为mylib
的目录下。
# 最后,将这两个目录(include
和mylib
)都放置在lib
目录下,此时就可以把lib
提供给别人使用了。
# 同样为了方便管理,我们也可以定义一个makefile
文件。
libmyc.so:mystdio.o mystring.ogcc -shared -o $@ $^%.o:%.c #展开所有.c文件生成对应的.o文件gcc -fPIC -c $<.PHONY:clean
clean:rm -rf ./*.o libmyc.so lib.tgz.PHONY:output #发表库
output:mkdir -p lib/includemkdir -p lib/mylibcp -f ./*.h lib/includecp -f ./*.so lib/mylibtar czf lib.tgz lib
2、动态库的使用
# 我们如果使用我们打包的动态库,使用gcc
编译时同样需要带有以下三个选项:
-I
:指定头文件搜索路径。-L
:指定库文件搜索路径。-l
:指明需要链接库文件路径下的哪一个库。
# 因为在程序执行时,编译器同样并不知晓我们所声明的头文件以及链接库的具体位置,而且链接库中可能存在不同的库文件。因此,我们需要在命令行中指定头文件的搜索路径,库文件的搜索路径,以及具体使用哪个库。
# 比如我们需要执行main.c
,其中main.c
中使用动态库中的函数。
# 但是与静态库不同的是,我们并不能直接执行main这个可执行文件。
# 为什么使用了-I
,-L
,-l
这三个选项,还是没有找到对应的动态库呢?
这是由于我们使用
-I
,-L
,-l
这三个选项仅仅是在编译期间向编译器告知我们所使用的头文件和库文件的具体位置以及具体的库名。然而,当可执行程序生成后,它便与编译器不再有直接关系。所以,该可执行程序运行起来时,操作系统仍找不到该可执行程序所依赖的动态库。
# 那么静态库为什么没有这个问题?
因为静态库是把库在链接时拷贝到可执行程序里面,只要链接编译成功后,就不需要依赖静态库。而动态库是需要加载程序的同时找到你所依赖的库!
# 所以其实只需要让系统可以找到我们可执行程序需要的库即可,因此这里我们有四种方法:
1. 第一种就是将库文件拷贝到系统共享的库路径下。
sudo cp lib/mylib/libmyc.so /lib64
# 但是这种方法可能会对系统文件造成污染,所以我们一般不采取该方法。
2. 第二种就是给我们的库路径建立一个软链接。
ln -s lib/mylib/libmyc.so /lib64/libmyc.so
3. 第三种就是更改环境变量LD_LIBRARY_PATH。
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/tata/lesson11/my_stdio/lib/mylib(对应动态库所在路径)
# LD_LIBRARY_PATH
是程序运行动态查找库时所要搜索的路径,我们只需将动态库所在的目录路径添加到LD_LIBRARY_PATH
环境变量当中,程序运行起来时就能找到对应的路径下的动态库。
# 但是我们知道环境变量在重启时会自动恢复,所以这种方法只在当前状态下有效,具有临时性。
4. 第四种就是配置.conf/文件。
# 在系统中,/etc/ld.so.conf.d/是用于搜索动态库的路径。此路径下存放的全是后缀为.conf的配置文件,这些配置文件中所存放的内容都是动态库的路径。
# 因此,若将自己库文件的路径也放置在该路径下,那么当可执行程序运行时,系统就能够找到我们的库文件。并且这种行为是永久的,并不会随重启而改变。
# 首先我们将对应的库文件所在地址写入一个.conf文件中,然后将其导入/etc/ld.so.conf.d/路径,最后使用指令ldconfig更新一下配置文件,最后我们就能执行我们的可执行文件了。
四、动静态库的使用
# 在Linux
下,我们可以通过ldd 文件名
来查看一个可执行程序所依赖的库文件。这其中的libc.so.6
就是该可执行程序所依赖的库文件,我们通过ls命令可以发现libc.so.6
实际上只是一个软链接。
# 实际上该软链接的源文件libc-2.17.so
和libc.so.6
在同一个目录下,为了进一步了解,我们可以通过file 文件名
命令来查看libc-2.17.so
的文件类型。
# 如果文件所链接的库中动静态库同时存在呢?
# 此时我们链接程序可以链接成功,也可以正常运行程序,然后我们ldd查看,发现他使用的动态库,链接是采用动态链接!
# 通过上图观察,我们知道gcc/g++
编译器默认都是动态链接的。
# 如果想使用静态链接,需要在后面加一个-static
。且一旦要静态链接就必须存在静态库。如果你并没有安装对应的静态库的话,可以使用以下指令安装。
sudo yum install glibc-static
sudo yum install libstdc++-static
五、目标文件
# 编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们⼀般都是⼀键构建⾮常⽅便,但⼀旦遇到错误的时候呢,尤其是链接相关的错误,很多⼈就束⼿⽆策了。在Linux下,我们之前也学习过如何通过gcc编译器来完成这⼀系列操作。
# 接下来我们深入探讨一下编译和链接的整个过程,来更好的理解动态静态库的使用原理。
# 先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。
# 比如:在一个源文件 hello.c 里便简单输出"hello world!"
,并且调用一个run函数,而这个函数被定义在另一个源文件 code.c
中。这里我们就可以调用 gcc -c 来分别编译这两个源文件。
// hello.c
#include <stdio.h>void run();
int main() {printf("hello world!\n");run();return 0;
}// code.c
#include<stdio.h>
void run {printf("running... "n");
}
// 编译两个源⽂件
$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c code.o hello.c hello.o
# 可以看到,在编译之后会生成两个扩展名为.o的文件,它们被称作目标文件。要注意的是如果我们修改了一个原文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程。目标文件是一个二进制的文件,文件的格式是ELF,是对二进制代码的一种封装。
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
## file命令⽤于辨识⽂件类型
六、ELF文件
1、ELF格式
# 要理解编译链接的细节,我们不得不了解一下ELF文件。其实以下4种文件其实都是ELF文件:
- 可重定位目标文件(Relocatable File):即 xxx.o 文件。包含适合与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。
- 可执行文件(Executable File):即可执行程序。
- 共享目标文件(Shared Object File):即 xxx.so 文件。
- 内核转储(core dumps),存放当前进程的执行上下文,用于dump信号触发。
# 一个ELF文件由以下四部分组成:
- ELF头(ELF header):描述文件的主要特征。其位于文件的开始位置,它的主要目的是定位文件的其他部分。
- 程序头表(Program header table):列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中,需要段表的描述信息,才能把他们每个段分割开。
- 节头表(Section header table):包含对节(sections)的描述。
- 节(Section):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。
# 最常见的节:
- 代码节(text):用于保存机器指令,是程序的主要执行部分。
- 数据节(data):保存已初始化的全局变量和局部静态变量。
2、ELF形成可执行
- step-1:将多份 C/C++ 源代码,翻译成为目标 .o 文件 + 动静态库ELF
- step-2:将多份 .o 文件
section
进行合并
注意:实际合并是在链接时进⾏的,但是并不是这么简单的合并,也会涉及对库合并,此处不做
过多追究。
3、ELF可执⾏⽂件加载
3.1 Section Header Table
# 一个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成Segment。
- 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等。
- 这样,即便是不同的Section,在加载到内存中,可能会以Segment的形式,加载到一起。
- 很显然,这个合并工作也已经在形成ELF的时候,合并方式已经确定了,具体合并原则被记录在了ELF的程序头表(Program header table)中。将来加载程序时哪些数据节是在一块加载由这个表指明。
# 我们可以通过readelf命令读取可执行程序的ELF,-S选项就可以读取ELF的Section Header Table,从而读取所有的数据节。这里我们读取Is命令的ELF:
# 我们看到,Section Header Table就是大小为30的数组,数组里面存储每个section的信息,而我们的ELF格式就是一个大文件,而我们要定位一个section只需要知道section相对于文件开头的偏移量+section长度即可。
# 所以我们可以把二进制或文本文件想象为一个一维数组,数组里面的元素就是一个一个的字节,所以我们不管要定位section还是ELF Header,或者是其他区域,我们只要知道相对于文件开头的偏移量+section长度,即可定位每一个区域。
# 上图这里的Address和Offset就是偏移量和长度,.text就是我们的代码段,.data就是我们的全局变量。因为我们的全局变量在加载的时候就要确定好,所以在可执行程序里面就给我们形成了。
3.2 Program Header Table
# 我们可以通过readelf命令读取合并之后的segment,加上-l选项就可以读取Program Header Table,这里我们读取Is命令的:
# 我们可以看到一共有13个segment在文件偏移呈64的位置,LOAD表示将来要加载到内存中的区域。也可以发现.data和.bss都是合并在5号segment中,所以已初始化数据和未初始化数据都加载一块了,其实我们的.rodata只读数据区和.text代码区其实也是加载到一块的,但是这个机器没有那么做而已。
# 所以我们把这些数据节和合并为数据段将来进行整体加载,将来加载时操作系统读取Program Header Table表,根据偏移量位置+长度,找到对应若干个数据节的segment,然后进行加载到空间里此时就完成了加载的过程!
# 结论:所以其实我们ELF已经在链接时把如何加载的问题确定。同时我们可以看到这里还有一个FLags,他表示该分段是否可读写,所以我们的程序如何知道代码段是只读的哪些段是可读可写的页表的权限位信息从哪里来?其实都是操作系统读取Program Header Table的信息,然后初始化页表的权限位信息即可。
# 那么 程序头表 和 节头表 ⼜有什么⽤呢,其实 ELF ⽂件提供 2 个不同的视图/视⻆来让我们理解这两个部分:
所以:
程序头表(Program Header Table)的作用:链接时把每个.o文件的.data和.text合并为segment,然后更新Section Header Table,表面可执行程序是如何形成的,包含每个节的属性,如是否可读写等,是被链接器使用的。
节头表(Section Header Table)的作用:加载时也会形成Program Header Table,他会告诉操作系统如何加载可执行程序,如何完成内存等初始化,是被加载器使用的。
# 我们链接时会把多个.o的.text和.data合并为segment,而真正合并是在加载器加载时完成的!
3.3 ELF Header
# ELF Header保存的是整个ELF的管理信息,例如每个区域的开始和结束位置。
# 我们可以通过readelf指令,-h选项可以查看目标文件的ELF Header信息:
# Magic魔数,文件开头的一组特定字节序列,通常位于文件的开头,不同的文件格式都有其特定的魔数,通过检查文件的魔数,系统可以快速判断文件的类型。
// 查看⽬标⽂件
$ readelf -h hello.o
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00Class: ELF64 # ⽂件类型Data: 2's complement, little endian # 指定的编码⽅式Version: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: REL (Relocatable file) # 指出ELF⽂件的类型Machine: Advanced Micro Devices X86-64 # 该程序需要的体系结构Version: 0x1Entry point address: 0x0 # 系统第⼀个传输控制的虚拟地址,在那启动进程。假如⽂件没有如何关联的⼊⼝点,该成员就保持为0。Start of program headers: 0 (bytes into file)Start of section headers: 728 (bytes into file)Flags: 0x0Size of this header: 64 (bytes) # 保存着ELF头⼤⼩(以字节计数)Size of program headers: 0 (bytes) # 保存着在⽂件的程序头表(program header table)中⼀个⼊⼝的⼤⼩Number of program headers: 0 # 保存着在程序头表中⼊⼝的个数。因此,e_phentsize和e_phnum的乘机就是表的⼤⼩(以字节计数).假如没有程序头表,变量为0。Size of section headers: 64 (bytes) # 保存着section头的⼤⼩(以字节计数)。⼀个section头是在section头表的⼀个⼊⼝Number of section headers: 13 # 保存着在section header table中的⼊⼝数⽬。因此,e_shentsize和e_shnum的乘积就是section头表的⼤⼩(以字节计数)。假如⽂件没有section头表,值为0。Section header string table index: 12 # 保存着跟section名字字符表相关⼊⼝的section头表(section header table)索引。
七、链接与加载
1、静态链接
# ⽆论是⾃⼰的.o,还是静态库中的.o,本质都是把.o⽂件进⾏连接的过程。所以:研究静态链接,本质就是研究.o是如何链接的。
# 这里我们通过一段代码来观察:
// hello.c#include<stdio.h>void run();int main()
{printf("hello tata!\n");run();return 0;
}
// code.c#include<stdio.h>void run()
{printf("running...\n");
}
# 这里我们在hello.c写了一个main函数,里面使用了run函数,而run函数的实现在code.c文件。然后把这两个文件编译成.o文件,链接两个.o文件形成main.exe。他们都使用了printf,因此两个.o文件都要和C标准库链接,然后.o之间也要相互连接,因为hello.c调用了run函数。
# 因为这两个.o是合并形成了main.exe,所以动态链接只有C标准库。
# 然后我们可以使用objdump -d 目标文件指令,对目标文件的代码段(.text)进行返回反汇编。这里我们对code.o和hello.o进行反汇编后写入.s文件中。
# 通过反汇编我们可以知道call其实就是调用函数,这里两个call就是调用printf和run。然后call汇编指令转化为机器码就是e8 xx xx xx xx ,这个e8就是call命令的机器码,xx xx xx xx就是调用的函数地址。而此时xx xx xx xx为全0,是由于此时我们函数地址并没有填充,因为我们只是对hello.o进行反汇编并没有链接,因为我们printf和run要和C标准库和.o链接才可以知道要调用的函数实现,才能得到函数地址,所以我们的code.c的run调用printf的汇编地址也是0,因为也没有链接C标准库,所以只能暂时设为0。
# 这里我们使用readelf -s 目标文件读取目标文件的符号表:
# 我们可以看到run和puts,其实printf底层调用的就是puts,我们都看到他们两个方法都是UND未定义的,code.c调用printf也是一样,但是code.c是run不是UND未定义的,因为他已经实现了run函数,所以将来链接的时候hello.c的run就会去code.c的符号表找到run方法,但是他们的puts的都是未定义,他们又会去C标准库查找,此时所有调用的方法就都可以找到了,就完成链接了。
# 所以查看连接后的可执行程序main.exe的符号表,就发现run的就不是未定义的了,说明连接后就可以找到run方法了,但是因为我们是动态链接,所以puts还是未定义的。
# 我们看到run对应的Ndx的值为16,说明多个section合并后是处于第16个section的。
# 后我们又发现main.exe的第16个section就是.text,说明run和main都被合并了代码段。
# 然后我们反汇编链接后的可执行文件main.exe,发现我们的run和puts的地址已经被填充了,run填充call的地址就是我们run函数的地址1149,所以我们的链接时就会把我们call的0地址填充,修改call地址。
# 把所有的.oELF的section合并,完成了编址,此时就完成了静态链接。我们把连接过程对.o中,外部符号call后面的地址修改,叫做地址重定位。所以.o文件也叫做重定位目标文件,因为链接时地址会被修改。
# 静态链接就是把库中的.o进⾏合并,和上述过程⼀样。所以链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴的可执⾏⽂件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这其实就是静态链接的过程。
# 所以,链接过程中会涉及到对.o中外部符号进⾏地址重定位。
2、ELF加载与进程地址空间
2.1 虚拟/逻辑地址(平坦模式编址)
# 问题:一个可执行程序没有加载到内存里此时他有没有地址?
其实我们前面看到汇编要,定位一个函数或变量时,其实不是拿着变量名或函数名,而是以地址来定位,所以其实我们的代码中的变量名编译好后都变成了地址,同时我们的可执行程序没有加载到内存,他也有地址。
# 问题:我们的虚拟地址空间每个区域的开始和结束都记录在了mm_struct和vm_struct里面那他们里面的值从哪里来的?
从可执行程序ELF中每个sgment来,每个sgment都有自己的起始地址和结束地址的逻辑地址,用来初始化内核结构体中的start和end数据。也就是说加载时操作系统会读取Program Header Table中的相关字段,然后用可执行程序的逻辑地址直接初始化内核数据结构的start和end。
# 所以:虚拟地址空间机制,不光光OS要⽀持,编译器也要⽀持。
2.2 重新理解进程虚拟地址空间
# 我们的可执行程序的每行代码都有自己的地址,当加载可执行程序时,程序就会变为进程,操作系统就会为他申请task_struct、mm_struct等内核数据结构,而当我们的代码加载到内存的后,每一行代码都要占据物理内存空间,所以每一行代码也一定存在他的物理地址
# mm_struct存在代码区的start和end,而mm_struct的地址是虚拟地址,所以mm_struct的地址加载到页表左侧,物理地址加载到页表的右侧,此时虚拟到物理地址的映射关系就有了。
# 问题:CPU怎么知道你的可执行程序的其实地址是什么?也就是CPU怎么知道从哪里开始执行呢?
我们的CPU有一个指令寄存器EIP,用来存储当前执行指令的下一条地址。EIP是Program Header Table的一个字段,这个字段他记录了一个地址就是程序的入口地址。
# 所以加载的过程中,操作系统直接把我们Entry point address填充到CPU的EIP寄存器中,加载后页表的虚拟和物理地址也有了,此时CPU开始调度进程了。同时我们CPU还有CR3寄存器,他会指向当前进程的页表,根据EIP的地址,通过CR3查表就可以找到地址对应代码的指令,放入CPU中。如果该指令还call其他的虚拟地址,此时CPU就会继续拿着该地址进行查表继续上述过程。所以进入CPU的地址都是虚拟地址,此时CPU就不再关心物理地址。
# 现在我们总体上来谈可执行程序是如何加载到内存的:
3、动态链接与库加载
3.1 进程如何看到动态库
# 静态库不涉及加载的问题,因为静态库和.o链接合并形成可执行了,所以静态库就是以ELF为载体加载的。
# 而我们的可执行如果是动态链接,此时我们的可执行程序和动态库是两个独立文件,所以一旦我们的程序运行起来就需要对动态库进行查找,因为库也是一个独立的文件,所以就需要把动态库也加载到物理内存中。
# 那么如何让我们的进程看到动态库呢(动态库是如何和我们的可执行程序关联的)?
库也要建立页表的映射关系,经过页表映射关系映射到一个进程地址空间上的共享区上,此时程序一但调用库方法,只需要从代码区跳转到共享区,完成调用后再返回,即可完成库函数调用,而以前我们自己的调用就是从代码区内部跳转到代码区即可。
# 库函数调用步骤:
- 被进程看到 -- 动态库映射到进程地址空间
- 被进程调用 -- 在进程的地址空间中进行跳转
3.2 进程间如何共享库
# 如果是两个进程调用库的话,只需要让进程B也建立动态库和共享库的映射即可。
# 动态库的本质就是:在系统层面上把公共的部分抽取出来只保存一份,而静态链接则会出现重复代码,因为静态库是拷贝到.o文件中,就会在内存中加载多份。所以动态库也叫共享库。
3.3 动态链接
3.3.1 动态链接如何工作
# 动态链接其实远⽐静态链接要常⽤得多。⽐如我们查看下 main.exe 这个可执⾏程序依赖的动态库,会发现它就⽤到了⼀个c动态链接库:
# 这⾥的 libc.so是C语⾔的运⾏时库,⾥⾯提供了常⽤的标准输⼊输出⽂件字符串处理等等这些功能。那为什么编译器默认不使⽤静态链接呢?静态链接会将编译产⽣的所有⽬标⽂件,连同⽤到的各种库,合并形成⼀个独⽴的可执⾏⽂件,它不需要额外的依赖就可以运⾏。照理来说应该更加⽅便才对是吧?
# 静态链接最⼤的问题在于⽣成的⽂件体积⼤,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费⼤量的硬盘空间。
# 这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独⽴的动态链接库,等到程序运⾏的时候再将它们加载到内存,这样不但可以节省空间,因为同⼀个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享。
# 动态链接到底是如何⼯作的??
⾸先要交代⼀个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使⽤情况为它们动态分配⼀段内存。
当动态库被加载到内存以后,⼀旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
3.3.2 动态链接器
# 我们还可以发现:无论是我们的可执行程序还是系统的指令除了依赖C标准库,还都依赖一个linux-x86的动态库,其实所有的C程序都依赖这个库。这是为什么呢?
也就是说:链接时_start函数会帮我们加载程序所依赖的动态库,而我们的上面依赖的linux.so就是动态链接器,动态链接器负责加载动态库,他通过搜索环境变量LD_LIBRARY_PATH和配置文件/etc/ld.so.conf及其子配置文件)来找到动态库。
3.3.3 动态库中的相对地址
# 动态库为了随时进⾏加载,为了⽀持并映射到任意进程的任意位置,对动态库中的⽅法,统⼀编址,采⽤相对编址的⽅案进⾏编制的(其实可执⾏程序也⼀样,都要遵守平坦模式,只不过exe是直接加载的)。
3.3.4 程序与库的映射
📌 注意:
- 动态库也是⼀个⽂件,要访问也是要被先加载,要加载也是要被打开的
- 让我们的进程找到动态库的本质:也是⽂件操作,不过我们访问库函数,通过虚拟地址进⾏跳转访问的,所以需要把动态库映射到进程的地址空间中
3.3.5 库函数调用原理 -- 加载地址重定位
3.3.6 全局偏移量表GOT(global offset table)
问题;代码区不是只读的吗?怎么可以修改呢?
是的,代码区(.text)是只读的,可是我们还是想使用 起始地址+偏移量 的方式完成库函数调用,所以动态链接采用的做法是:在.data(可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移量表GOT。表中每一项都是本运行模块要引用的一个全局变量或函数的地址,因为.data区域是可读写的,所以可以支持动态进行修改。
# GOT 表本质:位于 .data
段的函数指针数组,存储外部函数/变量的绝对地址。
3.3.7 库间依赖 -- PLT机制
注意:
- 不仅仅有可执⾏程序调⽤库
- 库也会调⽤其他库!库之间是有依赖的,如何做到库和库之间互相调⽤也是与地址⽆关呢?
- 库中也有.GOT,和可执⾏⼀样!这也就是为什么⼤家为什么都是ELF的格式!